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.
- mcp_github_crunchtools/__init__.py +58 -0
- mcp_github_crunchtools/__main__.py +6 -0
- mcp_github_crunchtools/client.py +271 -0
- mcp_github_crunchtools/config.py +140 -0
- mcp_github_crunchtools/errors.py +65 -0
- mcp_github_crunchtools/models.py +215 -0
- mcp_github_crunchtools/server.py +496 -0
- mcp_github_crunchtools/tools/__init__.py +48 -0
- mcp_github_crunchtools/tools/actions.py +187 -0
- mcp_github_crunchtools/tools/files.py +124 -0
- mcp_github_crunchtools/tools/issues.py +224 -0
- mcp_github_crunchtools/tools/pull_requests.py +287 -0
- mcp_github_crunchtools/tools/search.py +81 -0
- mcp_github_crunchtools-1.0.1.dist-info/METADATA +263 -0
- mcp_github_crunchtools-1.0.1.dist-info/RECORD +18 -0
- mcp_github_crunchtools-1.0.1.dist-info/WHEEL +4 -0
- mcp_github_crunchtools-1.0.1.dist-info/entry_points.txt +2 -0
- mcp_github_crunchtools-1.0.1.dist-info/licenses/LICENSE +661 -0
|
@@ -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,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."""
|