workos-github-mcp-server 1.0.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_server/__init__.py +26 -0
- github_mcp_server/__main__.py +4 -0
- github_mcp_server/client.py +576 -0
- github_mcp_server/config.py +54 -0
- github_mcp_server/server.py +60 -0
- github_mcp_server/tools/__init__.py +1 -0
- github_mcp_server/tools/commits.py +35 -0
- github_mcp_server/tools/gists.py +45 -0
- github_mcp_server/tools/issues.py +139 -0
- github_mcp_server/tools/pulls.py +84 -0
- github_mcp_server/tools/repos.py +71 -0
- github_mcp_server/tools/users.py +26 -0
- workos_github_mcp_server-1.0.0.dist-info/METADATA +194 -0
- workos_github_mcp_server-1.0.0.dist-info/RECORD +18 -0
- workos_github_mcp_server-1.0.0.dist-info/WHEEL +5 -0
- workos_github_mcp_server-1.0.0.dist-info/entry_points.txt +2 -0
- workos_github_mcp_server-1.0.0.dist-info/licenses/LICENSE +21 -0
- workos_github_mcp_server-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""
|
|
2
|
+
GitHub MCP Server — Model Context Protocol server for GitHub operations.
|
|
3
|
+
|
|
4
|
+
An independent, generalized MCP server that can connect to any AI agent
|
|
5
|
+
supporting the Model Context Protocol. Not tied to any specific project.
|
|
6
|
+
|
|
7
|
+
Quick start:
|
|
8
|
+
pip install workos-github-mcp-server
|
|
9
|
+
GITHUB_TOKEN=ghp_... github-mcp-server
|
|
10
|
+
|
|
11
|
+
Or run as a module:
|
|
12
|
+
python -m github_mcp_server
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "1.0.0"
|
|
16
|
+
|
|
17
|
+
from github_mcp_server.server import create_server
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main():
|
|
21
|
+
"""Entry point for the github-mcp-server CLI command."""
|
|
22
|
+
server = create_server()
|
|
23
|
+
server.run()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["main", "create_server", "__version__"]
|
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Async GitHub REST API Client.
|
|
3
|
+
|
|
4
|
+
Production-quality client with:
|
|
5
|
+
- Persistent connection pooling (httpx.AsyncClient)
|
|
6
|
+
- Automatic retry with exponential backoff (429, 5xx)
|
|
7
|
+
- Rate limit handling (X-RateLimit-* / Retry-After headers)
|
|
8
|
+
- Structured error types
|
|
9
|
+
- Proper resource cleanup
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import logging
|
|
14
|
+
import sys
|
|
15
|
+
from typing import Any, Optional
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("github_mcp_server.client")
|
|
20
|
+
|
|
21
|
+
# Configure logging to stderr (required for stdio MCP transport)
|
|
22
|
+
logging.basicConfig(
|
|
23
|
+
level=logging.INFO,
|
|
24
|
+
format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
|
|
25
|
+
stream=sys.stderr,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --- Error Types ---
|
|
30
|
+
|
|
31
|
+
class GitHubAPIError(Exception):
|
|
32
|
+
"""Base error for GitHub API failures."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, status: int, message: str = ""):
|
|
35
|
+
self.status = status
|
|
36
|
+
self.message = message
|
|
37
|
+
super().__init__(self.message)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class GitHubAuthError(GitHubAPIError):
|
|
41
|
+
"""Authentication/authorization failure (401/403)."""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GitHubNotFoundError(GitHubAPIError):
|
|
46
|
+
"""Resource not found (404)."""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class GitHubRateLimitError(GitHubAPIError):
|
|
51
|
+
"""Rate limit exceeded (403 with rate-limit headers or 429)."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, retry_after: int = 60):
|
|
54
|
+
self.retry_after = retry_after
|
|
55
|
+
super().__init__(429, f"Rate limited. Retry after {retry_after}s")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class GitHubValidationError(GitHubAPIError):
|
|
59
|
+
"""Validation error (422) — invalid request body."""
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- Client ---
|
|
64
|
+
|
|
65
|
+
class GitHubClient:
|
|
66
|
+
"""Async GitHub REST API client with connection pooling and retry."""
|
|
67
|
+
|
|
68
|
+
BASE_URL = "https://api.github.com"
|
|
69
|
+
MAX_RETRIES = 3
|
|
70
|
+
BACKOFF_BASE = 1.0 # seconds
|
|
71
|
+
|
|
72
|
+
def __init__(self, token: str):
|
|
73
|
+
if not token:
|
|
74
|
+
raise GitHubAuthError(401, "GITHUB_TOKEN is required")
|
|
75
|
+
self.token = token
|
|
76
|
+
|
|
77
|
+
self._client = httpx.AsyncClient(
|
|
78
|
+
base_url=self.BASE_URL,
|
|
79
|
+
headers={
|
|
80
|
+
"Authorization": f"Bearer {token}",
|
|
81
|
+
"Accept": "application/vnd.github+json",
|
|
82
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
83
|
+
},
|
|
84
|
+
timeout=30.0,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
async def close(self):
|
|
88
|
+
"""Close underlying HTTP client."""
|
|
89
|
+
await self._client.aclose()
|
|
90
|
+
|
|
91
|
+
async def __aenter__(self):
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
async def __aexit__(self, *args):
|
|
95
|
+
await self.close()
|
|
96
|
+
|
|
97
|
+
async def _request(
|
|
98
|
+
self,
|
|
99
|
+
method: str,
|
|
100
|
+
path: str,
|
|
101
|
+
**kwargs,
|
|
102
|
+
) -> Any:
|
|
103
|
+
"""Make an authenticated request with retry logic."""
|
|
104
|
+
for attempt in range(self.MAX_RETRIES):
|
|
105
|
+
try:
|
|
106
|
+
resp = await self._client.request(
|
|
107
|
+
method, path,
|
|
108
|
+
params=kwargs.get("params"),
|
|
109
|
+
json=kwargs.get("json"),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Rate limit (429 or 403 with rate-limit header)
|
|
113
|
+
if resp.status_code == 429 or (
|
|
114
|
+
resp.status_code == 403
|
|
115
|
+
and resp.headers.get("x-ratelimit-remaining") == "0"
|
|
116
|
+
):
|
|
117
|
+
retry_after = int(
|
|
118
|
+
resp.headers.get("retry-after",
|
|
119
|
+
resp.headers.get("x-ratelimit-reset", "60"))
|
|
120
|
+
)
|
|
121
|
+
# Clamp to reasonable wait
|
|
122
|
+
retry_after = min(retry_after, 120)
|
|
123
|
+
if attempt < self.MAX_RETRIES - 1:
|
|
124
|
+
logger.warning(
|
|
125
|
+
"Rate limited, retrying after %ds (attempt %d/%d)",
|
|
126
|
+
retry_after, attempt + 1, self.MAX_RETRIES,
|
|
127
|
+
)
|
|
128
|
+
await asyncio.sleep(retry_after)
|
|
129
|
+
continue
|
|
130
|
+
raise GitHubRateLimitError(retry_after)
|
|
131
|
+
|
|
132
|
+
# Server errors — retry
|
|
133
|
+
if resp.status_code >= 500:
|
|
134
|
+
if attempt < self.MAX_RETRIES - 1:
|
|
135
|
+
delay = self.BACKOFF_BASE * (2 ** attempt)
|
|
136
|
+
logger.warning(
|
|
137
|
+
"Server error %d, retrying in %.1fs (attempt %d/%d)",
|
|
138
|
+
resp.status_code, delay, attempt + 1, self.MAX_RETRIES,
|
|
139
|
+
)
|
|
140
|
+
await asyncio.sleep(delay)
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# Auth errors
|
|
144
|
+
if resp.status_code in (401, 403):
|
|
145
|
+
data = resp.json()
|
|
146
|
+
raise GitHubAuthError(
|
|
147
|
+
resp.status_code,
|
|
148
|
+
data.get("message", "Authentication failed"),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Not found
|
|
152
|
+
if resp.status_code == 404:
|
|
153
|
+
data = resp.json()
|
|
154
|
+
raise GitHubNotFoundError(
|
|
155
|
+
404, data.get("message", "Resource not found")
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Validation error
|
|
159
|
+
if resp.status_code == 422:
|
|
160
|
+
data = resp.json()
|
|
161
|
+
raise GitHubValidationError(
|
|
162
|
+
422, data.get("message", "Validation failed")
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
# Any other error
|
|
166
|
+
if resp.status_code >= 400:
|
|
167
|
+
data = resp.json()
|
|
168
|
+
raise GitHubAPIError(
|
|
169
|
+
resp.status_code,
|
|
170
|
+
data.get("message", f"HTTP {resp.status_code}"),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# 204 No Content
|
|
174
|
+
if resp.status_code == 204:
|
|
175
|
+
return {}
|
|
176
|
+
|
|
177
|
+
return resp.json()
|
|
178
|
+
|
|
179
|
+
except httpx.RequestError as e:
|
|
180
|
+
if attempt < self.MAX_RETRIES - 1:
|
|
181
|
+
delay = self.BACKOFF_BASE * (2 ** attempt)
|
|
182
|
+
logger.warning(
|
|
183
|
+
"Request error: %s, retrying in %.1fs", str(e), delay
|
|
184
|
+
)
|
|
185
|
+
await asyncio.sleep(delay)
|
|
186
|
+
continue
|
|
187
|
+
raise GitHubAPIError(0, str(e))
|
|
188
|
+
|
|
189
|
+
raise GitHubAPIError(0, "Failed after maximum retries")
|
|
190
|
+
|
|
191
|
+
# --- Repositories ---
|
|
192
|
+
|
|
193
|
+
async def list_repos(
|
|
194
|
+
self,
|
|
195
|
+
owner: Optional[str] = None,
|
|
196
|
+
repo_type: str = "owner",
|
|
197
|
+
sort: str = "updated",
|
|
198
|
+
per_page: int = 30,
|
|
199
|
+
) -> list[dict]:
|
|
200
|
+
"""List repositories for the authenticated user or a specific owner."""
|
|
201
|
+
if owner:
|
|
202
|
+
data = await self._request(
|
|
203
|
+
"GET", f"/users/{owner}/repos",
|
|
204
|
+
params={"type": repo_type, "sort": sort, "per_page": per_page},
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
data = await self._request(
|
|
208
|
+
"GET", "/user/repos",
|
|
209
|
+
params={"type": repo_type, "sort": sort, "per_page": per_page},
|
|
210
|
+
)
|
|
211
|
+
return [
|
|
212
|
+
{
|
|
213
|
+
"full_name": r["full_name"],
|
|
214
|
+
"description": r.get("description", ""),
|
|
215
|
+
"language": r.get("language", ""),
|
|
216
|
+
"stars": r.get("stargazers_count", 0),
|
|
217
|
+
"forks": r.get("forks_count", 0),
|
|
218
|
+
"private": r.get("private", False),
|
|
219
|
+
"url": r.get("html_url", ""),
|
|
220
|
+
"updated_at": r.get("updated_at", ""),
|
|
221
|
+
}
|
|
222
|
+
for r in data
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
async def get_repo(self, owner: str, repo: str) -> dict:
|
|
226
|
+
"""Get detailed information about a repository."""
|
|
227
|
+
data = await self._request("GET", f"/repos/{owner}/{repo}")
|
|
228
|
+
return {
|
|
229
|
+
"full_name": data["full_name"],
|
|
230
|
+
"description": data.get("description", ""),
|
|
231
|
+
"language": data.get("language", ""),
|
|
232
|
+
"stars": data.get("stargazers_count", 0),
|
|
233
|
+
"forks": data.get("forks_count", 0),
|
|
234
|
+
"open_issues": data.get("open_issues_count", 0),
|
|
235
|
+
"private": data.get("private", False),
|
|
236
|
+
"default_branch": data.get("default_branch", "main"),
|
|
237
|
+
"url": data.get("html_url", ""),
|
|
238
|
+
"created_at": data.get("created_at", ""),
|
|
239
|
+
"updated_at": data.get("updated_at", ""),
|
|
240
|
+
"topics": data.get("topics", []),
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async def search_repos(
|
|
244
|
+
self, query: str, sort: str = "stars", per_page: int = 10
|
|
245
|
+
) -> list[dict]:
|
|
246
|
+
"""Search repositories on GitHub."""
|
|
247
|
+
data = await self._request(
|
|
248
|
+
"GET", "/search/repositories",
|
|
249
|
+
params={"q": query, "sort": sort, "per_page": per_page},
|
|
250
|
+
)
|
|
251
|
+
return [
|
|
252
|
+
{
|
|
253
|
+
"full_name": r["full_name"],
|
|
254
|
+
"description": r.get("description", ""),
|
|
255
|
+
"language": r.get("language", ""),
|
|
256
|
+
"stars": r.get("stargazers_count", 0),
|
|
257
|
+
"url": r.get("html_url", ""),
|
|
258
|
+
}
|
|
259
|
+
for r in data.get("items", [])
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
# --- Issues ---
|
|
263
|
+
|
|
264
|
+
async def list_issues(
|
|
265
|
+
self,
|
|
266
|
+
owner: str,
|
|
267
|
+
repo: str,
|
|
268
|
+
state: str = "open",
|
|
269
|
+
per_page: int = 30,
|
|
270
|
+
) -> list[dict]:
|
|
271
|
+
"""List issues in a repository."""
|
|
272
|
+
data = await self._request(
|
|
273
|
+
"GET", f"/repos/{owner}/{repo}/issues",
|
|
274
|
+
params={"state": state, "per_page": per_page},
|
|
275
|
+
)
|
|
276
|
+
return [
|
|
277
|
+
{
|
|
278
|
+
"number": i["number"],
|
|
279
|
+
"title": i["title"],
|
|
280
|
+
"state": i["state"],
|
|
281
|
+
"user": i.get("user", {}).get("login", ""),
|
|
282
|
+
"labels": [l["name"] for l in i.get("labels", [])],
|
|
283
|
+
"created_at": i.get("created_at", ""),
|
|
284
|
+
"url": i.get("html_url", ""),
|
|
285
|
+
}
|
|
286
|
+
for i in data
|
|
287
|
+
if "pull_request" not in i # Exclude PRs from issues list
|
|
288
|
+
]
|
|
289
|
+
|
|
290
|
+
async def create_issue(
|
|
291
|
+
self,
|
|
292
|
+
owner: str,
|
|
293
|
+
repo: str,
|
|
294
|
+
title: str,
|
|
295
|
+
body: str = "",
|
|
296
|
+
labels: Optional[list[str]] = None,
|
|
297
|
+
assignees: Optional[list[str]] = None,
|
|
298
|
+
) -> dict:
|
|
299
|
+
"""Create a new issue in a repository."""
|
|
300
|
+
payload: dict[str, Any] = {"title": title}
|
|
301
|
+
if body:
|
|
302
|
+
payload["body"] = body
|
|
303
|
+
if labels:
|
|
304
|
+
payload["labels"] = labels
|
|
305
|
+
if assignees:
|
|
306
|
+
payload["assignees"] = assignees
|
|
307
|
+
data = await self._request(
|
|
308
|
+
"POST", f"/repos/{owner}/{repo}/issues", json=payload,
|
|
309
|
+
)
|
|
310
|
+
return {
|
|
311
|
+
"number": data["number"],
|
|
312
|
+
"title": data["title"],
|
|
313
|
+
"url": data.get("html_url", ""),
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
async def get_issue(self, owner: str, repo: str, issue_number: int) -> dict:
|
|
317
|
+
"""Get details of a specific issue."""
|
|
318
|
+
data = await self._request(
|
|
319
|
+
"GET", f"/repos/{owner}/{repo}/issues/{issue_number}",
|
|
320
|
+
)
|
|
321
|
+
return {
|
|
322
|
+
"number": data["number"],
|
|
323
|
+
"title": data["title"],
|
|
324
|
+
"state": data["state"],
|
|
325
|
+
"body": data.get("body", ""),
|
|
326
|
+
"user": data.get("user", {}).get("login", ""),
|
|
327
|
+
"labels": [l["name"] for l in data.get("labels", [])],
|
|
328
|
+
"assignees": [a["login"] for a in data.get("assignees", [])],
|
|
329
|
+
"created_at": data.get("created_at", ""),
|
|
330
|
+
"updated_at": data.get("updated_at", ""),
|
|
331
|
+
"url": data.get("html_url", ""),
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async def update_issue(
|
|
335
|
+
self,
|
|
336
|
+
owner: str,
|
|
337
|
+
repo: str,
|
|
338
|
+
issue_number: int,
|
|
339
|
+
title: Optional[str] = None,
|
|
340
|
+
body: Optional[str] = None,
|
|
341
|
+
state: Optional[str] = None,
|
|
342
|
+
labels: Optional[list[str]] = None,
|
|
343
|
+
assignees: Optional[list[str]] = None,
|
|
344
|
+
) -> dict:
|
|
345
|
+
"""Update an existing issue."""
|
|
346
|
+
payload: dict[str, Any] = {}
|
|
347
|
+
if title is not None:
|
|
348
|
+
payload["title"] = title
|
|
349
|
+
if body is not None:
|
|
350
|
+
payload["body"] = body
|
|
351
|
+
if state is not None:
|
|
352
|
+
payload["state"] = state
|
|
353
|
+
if labels is not None:
|
|
354
|
+
payload["labels"] = labels
|
|
355
|
+
if assignees is not None:
|
|
356
|
+
payload["assignees"] = assignees
|
|
357
|
+
data = await self._request(
|
|
358
|
+
"PATCH", f"/repos/{owner}/{repo}/issues/{issue_number}", json=payload,
|
|
359
|
+
)
|
|
360
|
+
return {
|
|
361
|
+
"number": data["number"],
|
|
362
|
+
"title": data["title"],
|
|
363
|
+
"state": data["state"],
|
|
364
|
+
"url": data.get("html_url", ""),
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async def search_issues(
|
|
368
|
+
self, query: str, sort: str = "created", per_page: int = 10
|
|
369
|
+
) -> list[dict]:
|
|
370
|
+
"""Search issues and pull requests across GitHub."""
|
|
371
|
+
data = await self._request(
|
|
372
|
+
"GET", "/search/issues",
|
|
373
|
+
params={"q": query, "sort": sort, "per_page": per_page},
|
|
374
|
+
)
|
|
375
|
+
return [
|
|
376
|
+
{
|
|
377
|
+
"number": i["number"],
|
|
378
|
+
"title": i["title"],
|
|
379
|
+
"state": i["state"],
|
|
380
|
+
"repository": i.get("repository_url", "").split("/repos/")[-1],
|
|
381
|
+
"user": i.get("user", {}).get("login", ""),
|
|
382
|
+
"url": i.get("html_url", ""),
|
|
383
|
+
}
|
|
384
|
+
for i in data.get("items", [])
|
|
385
|
+
]
|
|
386
|
+
|
|
387
|
+
# --- Pull Requests ---
|
|
388
|
+
|
|
389
|
+
async def list_prs(
|
|
390
|
+
self,
|
|
391
|
+
owner: str,
|
|
392
|
+
repo: str,
|
|
393
|
+
state: str = "open",
|
|
394
|
+
per_page: int = 30,
|
|
395
|
+
) -> list[dict]:
|
|
396
|
+
"""List pull requests in a repository."""
|
|
397
|
+
data = await self._request(
|
|
398
|
+
"GET", f"/repos/{owner}/{repo}/pulls",
|
|
399
|
+
params={"state": state, "per_page": per_page},
|
|
400
|
+
)
|
|
401
|
+
return [
|
|
402
|
+
{
|
|
403
|
+
"number": pr["number"],
|
|
404
|
+
"title": pr["title"],
|
|
405
|
+
"state": pr["state"],
|
|
406
|
+
"user": pr.get("user", {}).get("login", ""),
|
|
407
|
+
"head": pr.get("head", {}).get("ref", ""),
|
|
408
|
+
"base": pr.get("base", {}).get("ref", ""),
|
|
409
|
+
"created_at": pr.get("created_at", ""),
|
|
410
|
+
"url": pr.get("html_url", ""),
|
|
411
|
+
}
|
|
412
|
+
for pr in data
|
|
413
|
+
]
|
|
414
|
+
|
|
415
|
+
async def get_pr(self, owner: str, repo: str, pr_number: int) -> dict:
|
|
416
|
+
"""Get details of a specific pull request."""
|
|
417
|
+
data = await self._request(
|
|
418
|
+
"GET", f"/repos/{owner}/{repo}/pulls/{pr_number}",
|
|
419
|
+
)
|
|
420
|
+
return {
|
|
421
|
+
"number": data["number"],
|
|
422
|
+
"title": data["title"],
|
|
423
|
+
"state": data["state"],
|
|
424
|
+
"body": data.get("body", ""),
|
|
425
|
+
"user": data.get("user", {}).get("login", ""),
|
|
426
|
+
"head": data.get("head", {}).get("ref", ""),
|
|
427
|
+
"base": data.get("base", {}).get("ref", ""),
|
|
428
|
+
"mergeable": data.get("mergeable"),
|
|
429
|
+
"merged": data.get("merged", False),
|
|
430
|
+
"additions": data.get("additions", 0),
|
|
431
|
+
"deletions": data.get("deletions", 0),
|
|
432
|
+
"changed_files": data.get("changed_files", 0),
|
|
433
|
+
"created_at": data.get("created_at", ""),
|
|
434
|
+
"url": data.get("html_url", ""),
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async def create_pr(
|
|
438
|
+
self,
|
|
439
|
+
owner: str,
|
|
440
|
+
repo: str,
|
|
441
|
+
title: str,
|
|
442
|
+
head: str,
|
|
443
|
+
base: str,
|
|
444
|
+
body: str = "",
|
|
445
|
+
draft: bool = False,
|
|
446
|
+
) -> dict:
|
|
447
|
+
"""Create a new pull request."""
|
|
448
|
+
payload: dict[str, Any] = {
|
|
449
|
+
"title": title,
|
|
450
|
+
"head": head,
|
|
451
|
+
"base": base,
|
|
452
|
+
}
|
|
453
|
+
if body:
|
|
454
|
+
payload["body"] = body
|
|
455
|
+
if draft:
|
|
456
|
+
payload["draft"] = draft
|
|
457
|
+
data = await self._request(
|
|
458
|
+
"POST", f"/repos/{owner}/{repo}/pulls", json=payload,
|
|
459
|
+
)
|
|
460
|
+
return {
|
|
461
|
+
"number": data["number"],
|
|
462
|
+
"title": data["title"],
|
|
463
|
+
"url": data.get("html_url", ""),
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
# --- Commits ---
|
|
467
|
+
|
|
468
|
+
async def list_commits(
|
|
469
|
+
self,
|
|
470
|
+
owner: str,
|
|
471
|
+
repo: str,
|
|
472
|
+
sha: Optional[str] = None,
|
|
473
|
+
per_page: int = 30,
|
|
474
|
+
) -> list[dict]:
|
|
475
|
+
"""List commits in a repository."""
|
|
476
|
+
params: dict[str, Any] = {"per_page": per_page}
|
|
477
|
+
if sha:
|
|
478
|
+
params["sha"] = sha
|
|
479
|
+
data = await self._request(
|
|
480
|
+
"GET", f"/repos/{owner}/{repo}/commits", params=params,
|
|
481
|
+
)
|
|
482
|
+
return [
|
|
483
|
+
{
|
|
484
|
+
"sha": c["sha"][:7],
|
|
485
|
+
"message": c.get("commit", {}).get("message", "").split("\n")[0],
|
|
486
|
+
"author": c.get("commit", {}).get("author", {}).get("name", ""),
|
|
487
|
+
"date": c.get("commit", {}).get("author", {}).get("date", ""),
|
|
488
|
+
"url": c.get("html_url", ""),
|
|
489
|
+
}
|
|
490
|
+
for c in data
|
|
491
|
+
]
|
|
492
|
+
|
|
493
|
+
# --- Search ---
|
|
494
|
+
|
|
495
|
+
async def search_code(
|
|
496
|
+
self, query: str, per_page: int = 10
|
|
497
|
+
) -> list[dict]:
|
|
498
|
+
"""Search code across GitHub repositories."""
|
|
499
|
+
data = await self._request(
|
|
500
|
+
"GET", "/search/code",
|
|
501
|
+
params={"q": query, "per_page": per_page},
|
|
502
|
+
)
|
|
503
|
+
return [
|
|
504
|
+
{
|
|
505
|
+
"name": item.get("name", ""),
|
|
506
|
+
"path": item.get("path", ""),
|
|
507
|
+
"repository": item.get("repository", {}).get("full_name", ""),
|
|
508
|
+
"url": item.get("html_url", ""),
|
|
509
|
+
}
|
|
510
|
+
for item in data.get("items", [])
|
|
511
|
+
]
|
|
512
|
+
|
|
513
|
+
# --- Users ---
|
|
514
|
+
|
|
515
|
+
async def get_user(self, username: Optional[str] = None) -> dict:
|
|
516
|
+
"""Get user profile. If no username, returns authenticated user."""
|
|
517
|
+
path = f"/users/{username}" if username else "/user"
|
|
518
|
+
data = await self._request("GET", path)
|
|
519
|
+
return {
|
|
520
|
+
"login": data.get("login", ""),
|
|
521
|
+
"name": data.get("name", ""),
|
|
522
|
+
"bio": data.get("bio", ""),
|
|
523
|
+
"company": data.get("company", ""),
|
|
524
|
+
"location": data.get("location", ""),
|
|
525
|
+
"email": data.get("email", ""),
|
|
526
|
+
"public_repos": data.get("public_repos", 0),
|
|
527
|
+
"followers": data.get("followers", 0),
|
|
528
|
+
"following": data.get("following", 0),
|
|
529
|
+
"url": data.get("html_url", ""),
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
# --- Gists ---
|
|
533
|
+
|
|
534
|
+
async def list_gists(self, per_page: int = 10) -> list[dict]:
|
|
535
|
+
"""List gists for the authenticated user."""
|
|
536
|
+
data = await self._request(
|
|
537
|
+
"GET", "/gists", params={"per_page": per_page},
|
|
538
|
+
)
|
|
539
|
+
return [
|
|
540
|
+
{
|
|
541
|
+
"id": g["id"],
|
|
542
|
+
"description": g.get("description", ""),
|
|
543
|
+
"public": g.get("public", True),
|
|
544
|
+
"files": list(g.get("files", {}).keys()),
|
|
545
|
+
"url": g.get("html_url", ""),
|
|
546
|
+
"created_at": g.get("created_at", ""),
|
|
547
|
+
}
|
|
548
|
+
for g in data
|
|
549
|
+
]
|
|
550
|
+
|
|
551
|
+
async def create_gist(
|
|
552
|
+
self,
|
|
553
|
+
files: dict[str, str],
|
|
554
|
+
description: str = "",
|
|
555
|
+
public: bool = False,
|
|
556
|
+
) -> dict:
|
|
557
|
+
"""Create a new gist.
|
|
558
|
+
|
|
559
|
+
Args:
|
|
560
|
+
files: Mapping of filename to file content.
|
|
561
|
+
description: Gist description.
|
|
562
|
+
public: Whether the gist is public.
|
|
563
|
+
"""
|
|
564
|
+
payload: dict[str, Any] = {
|
|
565
|
+
"files": {
|
|
566
|
+
name: {"content": content} for name, content in files.items()
|
|
567
|
+
},
|
|
568
|
+
"description": description,
|
|
569
|
+
"public": public,
|
|
570
|
+
}
|
|
571
|
+
data = await self._request("POST", "/gists", json=payload)
|
|
572
|
+
return {
|
|
573
|
+
"id": data["id"],
|
|
574
|
+
"url": data.get("html_url", ""),
|
|
575
|
+
"files": list(data.get("files", {}).keys()),
|
|
576
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Environment variable validation for GitHub MCP Server.
|
|
3
|
+
|
|
4
|
+
Required environment variables:
|
|
5
|
+
GITHUB_TOKEN: GitHub Personal Access Token (ghp_... or github_pat_...)
|
|
6
|
+
|
|
7
|
+
Optional:
|
|
8
|
+
GITHUB_LOG_LEVEL: Logging level (default: INFO)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import logging
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("github_mcp_server.config")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class GitHubConfig:
|
|
19
|
+
"""Validated GitHub configuration from environment variables."""
|
|
20
|
+
|
|
21
|
+
def __init__(self):
|
|
22
|
+
self.token: str = ""
|
|
23
|
+
self.log_level: str = "INFO"
|
|
24
|
+
self._load()
|
|
25
|
+
|
|
26
|
+
def _load(self):
|
|
27
|
+
self.token = os.environ.get("GITHUB_TOKEN", "")
|
|
28
|
+
self.log_level = os.environ.get("GITHUB_LOG_LEVEL", "INFO")
|
|
29
|
+
|
|
30
|
+
def validate(self) -> list[str]:
|
|
31
|
+
"""Validate configuration. Returns list of error messages (empty = valid)."""
|
|
32
|
+
errors = []
|
|
33
|
+
|
|
34
|
+
if not self.token:
|
|
35
|
+
errors.append(
|
|
36
|
+
"GITHUB_TOKEN is required. "
|
|
37
|
+
"Create one at https://github.com/settings/tokens"
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return errors
|
|
41
|
+
|
|
42
|
+
def validate_or_exit(self):
|
|
43
|
+
"""Validate configuration, exit with clear error if invalid."""
|
|
44
|
+
errors = self.validate()
|
|
45
|
+
if errors:
|
|
46
|
+
print("GitHub MCP Server configuration errors:", file=sys.stderr)
|
|
47
|
+
for err in errors:
|
|
48
|
+
print(f" ✗ {err}", file=sys.stderr)
|
|
49
|
+
print(
|
|
50
|
+
"\nSet the required environment variables and try again.",
|
|
51
|
+
file=sys.stderr,
|
|
52
|
+
)
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
logger.info("GitHub configuration validated successfully")
|