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.
@@ -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,4 @@
1
+ """Allow running as: python -m github_mcp_server"""
2
+ from github_mcp_server import main
3
+
4
+ main()
@@ -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")