mcp-github-crunchtools 1.0.1__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,58 @@
1
+ """MCP GitHub CrunchTools - Secure MCP server for GitHub.
2
+
3
+ A security-focused MCP server for GitHub issues, pull requests (diffs and
4
+ CI checks), repository files, and code/issue search. Works with github.com
5
+ and GitHub Enterprise Server.
6
+
7
+ Usage:
8
+ mcp-github-crunchtools
9
+
10
+ python -m mcp_github_crunchtools
11
+
12
+ uvx mcp-github-crunchtools
13
+
14
+ Environment Variables:
15
+ GITHUB_TOKEN: Required. GitHub Personal Access Token.
16
+ GITHUB_API_URL: Optional. API base URL (default: https://api.github.com).
17
+ GITHUB_DEFAULT_ORG: Optional. Default owner when a tool omits owner.
18
+
19
+ Example with Claude Code:
20
+ claude mcp add mcp-github-crunchtools \\
21
+ --env GITHUB_TOKEN=your_token_here \\
22
+ -- uvx mcp-github-crunchtools
23
+ """
24
+
25
+ import argparse
26
+
27
+ from .server import mcp
28
+
29
+ __version__ = "1.0.1"
30
+ __all__ = ["main", "mcp"]
31
+
32
+
33
+ def main() -> None:
34
+ """Main entry point for the MCP server."""
35
+ parser = argparse.ArgumentParser(description="MCP server for GitHub")
36
+ parser.add_argument(
37
+ "--transport",
38
+ choices=["stdio", "sse", "streamable-http"],
39
+ default="stdio",
40
+ help="Transport protocol (default: stdio)",
41
+ )
42
+ parser.add_argument(
43
+ "--host",
44
+ default="127.0.0.1",
45
+ help="Host to bind to for HTTP transports (default: 127.0.0.1)",
46
+ )
47
+ parser.add_argument(
48
+ "--port",
49
+ type=int,
50
+ default=8016,
51
+ help="Port to bind to for HTTP transports (default: 8016)",
52
+ )
53
+ args = parser.parse_args()
54
+
55
+ if args.transport == "stdio":
56
+ mcp.run()
57
+ else:
58
+ mcp.run(transport=args.transport, host=args.host, port=args.port)
@@ -0,0 +1,6 @@
1
+ """Entry point for python -m mcp_github_crunchtools."""
2
+
3
+ from . import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
@@ -0,0 +1,271 @@
1
+ """GitHub API client with security hardening.
2
+
3
+ This module provides a secure async HTTP client for the GitHub REST API.
4
+ All requests go through this client to ensure consistent security practices.
5
+ """
6
+
7
+ import logging
8
+ import re
9
+ from typing import Any
10
+
11
+ import httpx
12
+
13
+ from .config import get_config
14
+ from .errors import (
15
+ GitHubApiError,
16
+ NotFoundError,
17
+ PermissionDeniedError,
18
+ RateLimitError,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ MAX_RESPONSE_SIZE = 10 * 1024 * 1024
24
+ REQUEST_TIMEOUT = 30.0
25
+ GITHUB_API_VERSION = "2022-11-28"
26
+ DEFAULT_ACCEPT = "application/vnd.github+json"
27
+
28
+ _LINK_ENTRY = re.compile(r'<(?P<url>[^>]+)>;\s*rel="(?P<rel>[^"]+)"')
29
+ _PAGE_PARAM = re.compile(r"[?&]page=(\d+)")
30
+
31
+
32
+ class GitHubClient:
33
+ """Async HTTP client for the GitHub REST API.
34
+
35
+ Security features:
36
+ - Configurable base URL with HTTPS enforcement (supports GHES)
37
+ - Token passed via Authorization: Bearer header (not URL)
38
+ - TLS certificate validation (httpx default)
39
+ - Request timeout enforcement
40
+ - Response size limits
41
+ - Pagination support via the GitHub Link header
42
+ """
43
+
44
+ def __init__(self) -> None:
45
+ """Initialize the GitHub client."""
46
+ self._config = get_config()
47
+ self._client: httpx.AsyncClient | None = None
48
+
49
+ async def _get_client(self) -> httpx.AsyncClient:
50
+ """Get or create the async HTTP client."""
51
+ if self._client is None:
52
+ self._client = httpx.AsyncClient(
53
+ base_url=self._config.api_base_url,
54
+ headers={
55
+ "Authorization": f"Bearer {self._config.token}",
56
+ "Accept": DEFAULT_ACCEPT,
57
+ "X-GitHub-Api-Version": GITHUB_API_VERSION,
58
+ },
59
+ timeout=httpx.Timeout(REQUEST_TIMEOUT),
60
+ verify=self._config.ssl_verify,
61
+ )
62
+ return self._client
63
+
64
+ async def close(self) -> None:
65
+ """Close the HTTP client."""
66
+ if self._client is not None:
67
+ await self._client.aclose()
68
+ self._client = None
69
+
70
+ async def _request(
71
+ self,
72
+ method: str,
73
+ path: str,
74
+ params: dict[str, Any] | None = None,
75
+ json_data: dict[str, Any] | None = None,
76
+ accept: str | None = None,
77
+ ) -> dict[str, Any]:
78
+ """Make an API request with error handling.
79
+
80
+ Args:
81
+ method: HTTP method (GET, POST, PUT, DELETE)
82
+ path: API path (e.g., /repos/owner/repo/issues)
83
+ params: Query parameters
84
+ json_data: JSON body data
85
+ accept: Optional override for the Accept header (e.g., for diffs)
86
+
87
+ Returns:
88
+ API response data with pagination info if applicable. Non-JSON
89
+ responses (such as raw diffs) are returned as {"content": text}.
90
+
91
+ Raises:
92
+ GitHubApiError: On API errors, including non-rate-limit 403s
93
+ (e.g. re-running a workflow run older than 30 days), whose
94
+ original GitHub message is preserved so callers can tell a
95
+ real scope problem from other 403 conditions
96
+ RateLimitError: On rate limiting
97
+ PermissionDeniedError: On 401 authorization failures
98
+ NotFoundError: When the resource does not exist
99
+ """
100
+ client = await self._get_client()
101
+
102
+ logger.debug("API request: %s %s", method, path)
103
+
104
+ headers = {"Accept": accept} if accept else None
105
+
106
+ try:
107
+ response = await client.request(
108
+ method=method,
109
+ url=path,
110
+ params=params,
111
+ json=json_data,
112
+ headers=headers,
113
+ )
114
+ except httpx.TimeoutException as e:
115
+ raise GitHubApiError(0, f"Request timeout: {e}") from e
116
+ except httpx.RequestError as e:
117
+ raise GitHubApiError(0, f"Request failed: {e}") from e
118
+
119
+ content_length = response.headers.get("content-length")
120
+ if content_length and int(content_length) > MAX_RESPONSE_SIZE:
121
+ raise GitHubApiError(0, "Response too large")
122
+
123
+ if not response.is_success:
124
+ self._handle_error_response(response)
125
+
126
+ if response.status_code == 204:
127
+ return {"status": "deleted"}
128
+
129
+ content_type = response.headers.get("content-type", "")
130
+ if "application/json" not in content_type:
131
+ return {"content": response.text}
132
+
133
+ try:
134
+ parsed = response.json()
135
+ except ValueError as e:
136
+ raise GitHubApiError(
137
+ response.status_code, f"Invalid JSON response: {e}"
138
+ ) from e
139
+
140
+ if isinstance(parsed, list):
141
+ return self._wrap_list_response(parsed, response)
142
+
143
+ if isinstance(parsed, dict):
144
+ return parsed
145
+ return {"data": parsed}
146
+
147
+ def _wrap_list_response(
148
+ self, items: list[Any], response: httpx.Response
149
+ ) -> dict[str, Any]:
150
+ """Wrap a list response with pagination parsed from the Link header.
151
+
152
+ GitHub signals pagination through the RFC 5988 Link header rather than
153
+ x-total-* headers. Each rel (next, prev, first, last) maps to a URL; we
154
+ surface both the URLs and their page numbers.
155
+ """
156
+ wrapped: dict[str, Any] = {"items": items}
157
+
158
+ pagination: dict[str, Any] = {}
159
+ link_header = response.headers.get("link")
160
+ if link_header:
161
+ for match in _LINK_ENTRY.finditer(link_header):
162
+ rel = match.group("rel")
163
+ url = match.group("url")
164
+ pagination[f"{rel}_url"] = url
165
+ page_match = _PAGE_PARAM.search(url)
166
+ if page_match:
167
+ pagination[f"{rel}_page"] = int(page_match.group(1))
168
+
169
+ if pagination:
170
+ wrapped["pagination"] = pagination
171
+
172
+ return wrapped
173
+
174
+ def _handle_error_response(self, response: httpx.Response) -> None:
175
+ """Handle error responses from the API.
176
+
177
+ GitHub signals primary rate limiting with HTTP 403 (not 429) plus
178
+ ``x-ratelimit-remaining: 0``; both cases map to RateLimitError.
179
+
180
+ Args:
181
+ response: HTTP response
182
+
183
+ Raises:
184
+ Various UserError subclasses based on error type
185
+ """
186
+ status_code = response.status_code
187
+
188
+ error_msg: str = "Unknown error"
189
+ try:
190
+ error_body = response.json()
191
+ if isinstance(error_body, dict):
192
+ raw_msg = error_body.get("message", error_body.get("error"))
193
+ if isinstance(raw_msg, (dict, str, int, float)):
194
+ error_msg = str(raw_msg)
195
+ else:
196
+ error_msg = str(error_body)
197
+ except ValueError:
198
+ error_msg = response.text[:200] if response.text else "Unknown error"
199
+
200
+ rate_remaining = response.headers.get("x-ratelimit-remaining")
201
+
202
+ if status_code == 429 or (status_code == 403 and rate_remaining == "0"):
203
+ retry_after = response.headers.get("retry-after")
204
+ if retry_after is None:
205
+ retry_after = response.headers.get("x-ratelimit-reset")
206
+ raise RateLimitError(int(retry_after) if retry_after else None)
207
+
208
+ if status_code == 401:
209
+ raise PermissionDeniedError("Valid token with required scopes")
210
+ if status_code == 403:
211
+ raise GitHubApiError(status_code, error_msg)
212
+ if status_code == 404:
213
+ raise NotFoundError(error_msg)
214
+
215
+ raise GitHubApiError(status_code, error_msg)
216
+
217
+ async def get(
218
+ self,
219
+ path: str,
220
+ params: dict[str, Any] | None = None,
221
+ accept: str | None = None,
222
+ ) -> dict[str, Any]:
223
+ """Make a GET request.
224
+
225
+ Args:
226
+ path: API path
227
+ params: Query parameters
228
+ accept: Optional Accept header override (e.g.,
229
+ "application/vnd.github.diff" to retrieve a diff)
230
+ """
231
+ return await self._request("GET", path, params=params, accept=accept)
232
+
233
+ async def post(
234
+ self,
235
+ path: str,
236
+ json_data: dict[str, Any] | None = None,
237
+ params: dict[str, Any] | None = None,
238
+ ) -> dict[str, Any]:
239
+ """Make a POST request."""
240
+ return await self._request("POST", path, params=params, json_data=json_data)
241
+
242
+ async def put(
243
+ self,
244
+ path: str,
245
+ json_data: dict[str, Any] | None = None,
246
+ ) -> dict[str, Any]:
247
+ """Make a PUT request."""
248
+ return await self._request("PUT", path, json_data=json_data)
249
+
250
+ async def patch(
251
+ self,
252
+ path: str,
253
+ json_data: dict[str, Any] | None = None,
254
+ ) -> dict[str, Any]:
255
+ """Make a PATCH request."""
256
+ return await self._request("PATCH", path, json_data=json_data)
257
+
258
+ async def delete(self, path: str) -> dict[str, Any]:
259
+ """Make a DELETE request."""
260
+ return await self._request("DELETE", path)
261
+
262
+
263
+ _client: GitHubClient | None = None
264
+
265
+
266
+ def get_client() -> GitHubClient:
267
+ """Get the global GitHub client instance."""
268
+ global _client
269
+ if _client is None:
270
+ _client = GitHubClient()
271
+ return _client
@@ -0,0 +1,140 @@
1
+ """Secure configuration handling.
2
+
3
+ This module handles all configuration including the sensitive API token.
4
+ The token is stored as a SecretStr to prevent accidental logging.
5
+ """
6
+
7
+ import logging
8
+ import os
9
+ from urllib.parse import urlparse
10
+
11
+ from pydantic import SecretStr
12
+
13
+ from .errors import ConfigurationError
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class Config:
19
+ """Secure configuration handling.
20
+
21
+ The API token is stored as a SecretStr and should only be accessed
22
+ via the token property when actually needed for API calls.
23
+ """
24
+
25
+ def __init__(self) -> None:
26
+ """Initialize configuration from environment variables.
27
+
28
+ Raises:
29
+ ConfigurationError: If required environment variables are missing or invalid.
30
+ """
31
+ token = os.environ.get("GITHUB_TOKEN")
32
+ if not token:
33
+ raise ConfigurationError(
34
+ "GITHUB_TOKEN environment variable required. "
35
+ "Create a Personal Access Token at "
36
+ "https://github.com/settings/tokens"
37
+ )
38
+
39
+ self._token = SecretStr(token)
40
+
41
+ api_url = os.environ.get("GITHUB_API_URL", "https://api.github.com").rstrip("/")
42
+
43
+ parsed = urlparse(api_url)
44
+ if not parsed.scheme or not parsed.netloc:
45
+ raise ConfigurationError(
46
+ "Invalid GITHUB_API_URL: must be a valid URL (e.g. https://api.github.com)"
47
+ )
48
+
49
+ if parsed.scheme != "https" and parsed.hostname not in (
50
+ "localhost",
51
+ "127.0.0.1",
52
+ "::1",
53
+ ):
54
+ raise ConfigurationError(
55
+ "GITHUB_API_URL must use HTTPS for non-localhost URLs"
56
+ )
57
+
58
+ self._api_base_url = api_url
59
+
60
+ self._default_org = os.environ.get("GITHUB_DEFAULT_ORG") or None
61
+
62
+ ssl_disabled = os.environ.get("GITHUB_SSL_VERIFY", "true").lower() in (
63
+ "false",
64
+ "0",
65
+ "no",
66
+ )
67
+ ssl_cert_file = os.environ.get("SSL_CERT_FILE")
68
+
69
+ match (ssl_disabled, ssl_cert_file):
70
+ case (True, _):
71
+ self._ssl_verify: bool | str = False
72
+ logger.warning("SSL verification disabled via GITHUB_SSL_VERIFY")
73
+ case (False, str(cert_path)):
74
+ self._ssl_verify = cert_path
75
+ logger.info("Using custom CA bundle: %s", cert_path)
76
+ case _:
77
+ self._ssl_verify = True
78
+
79
+ logger.info(
80
+ "Configuration loaded successfully (GitHub API: %s)", self._api_base_url
81
+ )
82
+
83
+ @property
84
+ def token(self) -> str:
85
+ """Get token value for API calls.
86
+
87
+ Use sparingly - only when making actual API calls.
88
+ """
89
+ return self._token.get_secret_value()
90
+
91
+ @property
92
+ def api_base_url(self) -> str:
93
+ """GitHub REST API base URL.
94
+
95
+ Derived from GITHUB_API_URL environment variable
96
+ (default: https://api.github.com).
97
+ """
98
+ return self._api_base_url
99
+
100
+ @property
101
+ def default_org(self) -> str | None:
102
+ """Default owner/org used when a tool is called without an owner."""
103
+ return self._default_org
104
+
105
+ @property
106
+ def ssl_verify(self) -> bool | str:
107
+ """SSL verification setting.
108
+
109
+ Returns True, False, or a path to a CA bundle file.
110
+ """
111
+ return self._ssl_verify
112
+
113
+ def __repr__(self) -> str:
114
+ """Safe repr that never exposes the token."""
115
+ return f"Config(api_base_url={self._api_base_url}, token=***)"
116
+
117
+ def __str__(self) -> str:
118
+ """Safe str that never exposes the token."""
119
+ return f"Config(api_base_url={self._api_base_url}, token=***)"
120
+
121
+
122
+ _config: Config | None = None
123
+
124
+
125
+ def get_config() -> Config:
126
+ """Get the global configuration instance.
127
+
128
+ This function lazily initializes the configuration on first call.
129
+ Subsequent calls return the same instance.
130
+
131
+ Returns:
132
+ The global Config instance.
133
+
134
+ Raises:
135
+ ConfigurationError: If configuration is invalid.
136
+ """
137
+ global _config
138
+ if _config is None:
139
+ _config = Config()
140
+ return _config
@@ -0,0 +1,65 @@
1
+ """Safe error types that can be shown to users.
2
+
3
+ This module defines exception classes that are safe to expose to MCP clients.
4
+ Internal errors should be caught and converted to UserError before propagating.
5
+ """
6
+
7
+ import os
8
+
9
+ SAFE_ID_MAX_LENGTH = 40
10
+
11
+
12
+ class UserError(Exception):
13
+ """Base class for safe errors that can be shown to users.
14
+
15
+ All error messages in UserError subclasses must be carefully crafted
16
+ to avoid leaking sensitive information like API tokens or internal paths.
17
+ """
18
+
19
+
20
+ class ConfigurationError(UserError):
21
+ """Error in server configuration."""
22
+
23
+
24
+ class GitHubApiError(UserError):
25
+ """Error from GitHub API.
26
+
27
+ The message is sanitized to remove any potential token references.
28
+ """
29
+
30
+ def __init__(self, code: int, message: str) -> None:
31
+ token = os.environ.get("GITHUB_TOKEN", "")
32
+ safe_message = message.replace(token, "***") if token else message
33
+ super().__init__(f"GitHub API error {code}: {safe_message}")
34
+
35
+
36
+ class NotFoundError(UserError):
37
+ """Resource not found or not accessible."""
38
+
39
+ def __init__(self, identifier: str) -> None:
40
+ if len(identifier) > SAFE_ID_MAX_LENGTH:
41
+ safe_id = identifier[:SAFE_ID_MAX_LENGTH] + "..."
42
+ else:
43
+ safe_id = identifier
44
+ super().__init__(f"Not found or not accessible: {safe_id}")
45
+
46
+
47
+ class PermissionDeniedError(UserError):
48
+ """Permission denied for the requested operation."""
49
+
50
+ def __init__(self, required_scope: str) -> None:
51
+ super().__init__(f"Permission denied. Required scope: {required_scope}")
52
+
53
+
54
+ class RateLimitError(UserError):
55
+ """Rate limit exceeded."""
56
+
57
+ def __init__(self, retry_after: int | None = None) -> None:
58
+ msg = "Rate limit exceeded."
59
+ if retry_after:
60
+ msg += f" Retry after {retry_after} seconds."
61
+ super().__init__(msg)
62
+
63
+
64
+ class ValidationError(UserError):
65
+ """Input validation error."""