mcp-devops-assistant 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.
- devops_assistant/__init__.py +5 -0
- devops_assistant/actions.py +150 -0
- devops_assistant/app.py +96 -0
- devops_assistant/audit.py +129 -0
- devops_assistant/config.py +78 -0
- devops_assistant/errors.py +21 -0
- devops_assistant/prompts.py +28 -0
- devops_assistant/resources.py +96 -0
- devops_assistant/security.py +26 -0
- devops_assistant/services/__init__.py +1 -0
- devops_assistant/services/docker_client.py +259 -0
- devops_assistant/services/github_client.py +587 -0
- devops_assistant/tools/__init__.py +1 -0
- devops_assistant/tools/docker_read.py +57 -0
- devops_assistant/tools/github_read.py +102 -0
- devops_assistant/tools/write.py +161 -0
- mcp_devops_assistant-0.1.0.dist-info/METADATA +15 -0
- mcp_devops_assistant-0.1.0.dist-info/RECORD +21 -0
- mcp_devops_assistant-0.1.0.dist-info/WHEEL +5 -0
- mcp_devops_assistant-0.1.0.dist-info/entry_points.txt +3 -0
- mcp_devops_assistant-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import binascii
|
|
3
|
+
import re
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import quote
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
from fastmcp.exceptions import ToolError
|
|
9
|
+
|
|
10
|
+
from ..config import Settings
|
|
11
|
+
from ..errors import InvalidInputError, PolicyDeniedError
|
|
12
|
+
from ..security import limit, redact
|
|
13
|
+
|
|
14
|
+
_REPOSITORY_PATTERN = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
|
|
15
|
+
_MAX_PAGES = 5
|
|
16
|
+
_PER_PAGE = 100
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GitHubClient:
|
|
20
|
+
def __init__(self, settings: Settings) -> None:
|
|
21
|
+
self.settings = settings
|
|
22
|
+
self.base_url = settings.github_api_base_url.rstrip("/")
|
|
23
|
+
|
|
24
|
+
def require_write_token(self) -> None:
|
|
25
|
+
if not self.settings.github_write_token:
|
|
26
|
+
raise ToolError(
|
|
27
|
+
"GITHUB_WRITE_TOKEN is required for GitHub write operations"
|
|
28
|
+
)
|
|
29
|
+
if self.settings.github_write_token == self.settings.github_token:
|
|
30
|
+
raise ToolError("GitHub writes require a separate GITHUB_WRITE_TOKEN")
|
|
31
|
+
|
|
32
|
+
def validate_repository(self, repository: str) -> str:
|
|
33
|
+
if not _REPOSITORY_PATTERN.fullmatch(repository):
|
|
34
|
+
raise InvalidInputError("Repository must use the owner/repository format")
|
|
35
|
+
|
|
36
|
+
owner, name = repository.split("/", 1)
|
|
37
|
+
if owner in {".", ".."} or name in {".", ".."}:
|
|
38
|
+
raise InvalidInputError("Repository must use the owner/repository format")
|
|
39
|
+
|
|
40
|
+
if self.settings.allowed_github_repos and repository.casefold() not in {
|
|
41
|
+
item.casefold() for item in self.settings.allowed_github_repos
|
|
42
|
+
}:
|
|
43
|
+
raise PolicyDeniedError("Repository is not on the configured allowlist")
|
|
44
|
+
return repository
|
|
45
|
+
|
|
46
|
+
async def _request(self, path: str, params: dict[str, Any] | None = None) -> Any:
|
|
47
|
+
if not self.settings.github_token:
|
|
48
|
+
raise ToolError("GITHUB_TOKEN is required for GitHub tools")
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
async with httpx.AsyncClient(
|
|
52
|
+
timeout=10.0, follow_redirects=False
|
|
53
|
+
) as client:
|
|
54
|
+
response = await client.get(
|
|
55
|
+
f"{self.base_url}/{path.lstrip('/')}",
|
|
56
|
+
headers={
|
|
57
|
+
"Accept": "application/vnd.github+json",
|
|
58
|
+
"Authorization": f"Bearer {self.settings.github_token}",
|
|
59
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
60
|
+
},
|
|
61
|
+
params=params,
|
|
62
|
+
)
|
|
63
|
+
except httpx.TimeoutException as exc:
|
|
64
|
+
raise ToolError("GitHub API request timed out") from exc
|
|
65
|
+
except httpx.RequestError as exc:
|
|
66
|
+
raise ToolError("GitHub API is unavailable") from exc
|
|
67
|
+
|
|
68
|
+
if response.status_code == 401:
|
|
69
|
+
raise ToolError("GitHub rejected the configured token")
|
|
70
|
+
if response.status_code == 403:
|
|
71
|
+
if response.headers.get(
|
|
72
|
+
"X-RateLimit-Remaining"
|
|
73
|
+
) == "0" or response.headers.get("Retry-After"):
|
|
74
|
+
raise ToolError("GitHub API rate limit exceeded; try again later")
|
|
75
|
+
raise ToolError("GitHub denied the request; check token permissions")
|
|
76
|
+
if response.status_code == 404:
|
|
77
|
+
raise ToolError("GitHub resource was not found or is not accessible")
|
|
78
|
+
if response.status_code == 429:
|
|
79
|
+
raise ToolError("GitHub API rate limit exceeded; try again later")
|
|
80
|
+
if response.is_error:
|
|
81
|
+
raise ToolError(f"GitHub API returned HTTP {response.status_code}")
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
return response.json()
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
raise ToolError("GitHub API returned an invalid response") from exc
|
|
87
|
+
|
|
88
|
+
async def _paginate(
|
|
89
|
+
self, path: str, params: dict[str, Any] | None = None
|
|
90
|
+
) -> list[dict[str, Any]]:
|
|
91
|
+
query = dict(params or {})
|
|
92
|
+
query["per_page"] = _PER_PAGE
|
|
93
|
+
results: list[dict[str, Any]] = []
|
|
94
|
+
|
|
95
|
+
for page in range(1, _MAX_PAGES + 1):
|
|
96
|
+
query["page"] = page
|
|
97
|
+
data = await self._request(path, query)
|
|
98
|
+
if not isinstance(data, list):
|
|
99
|
+
raise ToolError("GitHub API returned an unexpected response")
|
|
100
|
+
results.extend(item for item in data if isinstance(item, dict))
|
|
101
|
+
if len(data) < _PER_PAGE:
|
|
102
|
+
break
|
|
103
|
+
|
|
104
|
+
return results
|
|
105
|
+
|
|
106
|
+
async def list_repositories(self, limit_count: int) -> list[dict[str, Any]]:
|
|
107
|
+
repositories = await self._paginate(
|
|
108
|
+
"user/repos", {"sort": "updated", "direction": "desc"}
|
|
109
|
+
)
|
|
110
|
+
if self.settings.allowed_github_repos:
|
|
111
|
+
allowed = {item.casefold() for item in self.settings.allowed_github_repos}
|
|
112
|
+
repositories = [
|
|
113
|
+
repository
|
|
114
|
+
for repository in repositories
|
|
115
|
+
if isinstance(repository.get("full_name"), str)
|
|
116
|
+
and repository["full_name"].casefold() in allowed
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
return [
|
|
120
|
+
self._shape(
|
|
121
|
+
{
|
|
122
|
+
"full_name": repository.get("full_name"),
|
|
123
|
+
"description": repository.get("description"),
|
|
124
|
+
"private": repository.get("private"),
|
|
125
|
+
"default_branch": repository.get("default_branch"),
|
|
126
|
+
"html_url": repository.get("html_url"),
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
for repository in repositories[:limit_count]
|
|
130
|
+
]
|
|
131
|
+
|
|
132
|
+
async def get_repository(self, repository: str) -> dict[str, Any]:
|
|
133
|
+
repository = self.validate_repository(repository)
|
|
134
|
+
data = await self._request(f"repos/{repository}")
|
|
135
|
+
if not isinstance(data, dict):
|
|
136
|
+
raise ToolError("GitHub API returned an unexpected repository response")
|
|
137
|
+
return self._shape(
|
|
138
|
+
{
|
|
139
|
+
"full_name": data.get("full_name"),
|
|
140
|
+
"description": data.get("description"),
|
|
141
|
+
"private": data.get("private"),
|
|
142
|
+
"default_branch": data.get("default_branch"),
|
|
143
|
+
"html_url": data.get("html_url"),
|
|
144
|
+
"language": data.get("language"),
|
|
145
|
+
"stargazers_count": data.get("stargazers_count"),
|
|
146
|
+
"open_issues_count": data.get("open_issues_count"),
|
|
147
|
+
}
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
async def list_files(
|
|
151
|
+
self, repository: str, path: str, ref: str | None, limit_count: int
|
|
152
|
+
) -> list[dict[str, Any]]:
|
|
153
|
+
repository = self.validate_repository(repository)
|
|
154
|
+
path = self._validate_path(path, allow_empty=True)
|
|
155
|
+
encoded_path = quote(path, safe="/")
|
|
156
|
+
params = {"ref": ref} if ref else None
|
|
157
|
+
data = await self._request(
|
|
158
|
+
f"repos/{repository}/contents/{encoded_path}", params
|
|
159
|
+
)
|
|
160
|
+
if not isinstance(data, list):
|
|
161
|
+
raise ToolError("The requested GitHub path is not a directory")
|
|
162
|
+
|
|
163
|
+
return [
|
|
164
|
+
self._shape(
|
|
165
|
+
{
|
|
166
|
+
"name": item.get("name"),
|
|
167
|
+
"path": item.get("path"),
|
|
168
|
+
"type": item.get("type"),
|
|
169
|
+
"size": item.get("size"),
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
for item in data[:limit_count]
|
|
173
|
+
if isinstance(item, dict)
|
|
174
|
+
]
|
|
175
|
+
|
|
176
|
+
async def read_file(self, repository: str, path: str, ref: str | None) -> str:
|
|
177
|
+
repository = self.validate_repository(repository)
|
|
178
|
+
path = self._validate_path(path, allow_empty=False)
|
|
179
|
+
encoded_path = quote(path, safe="/")
|
|
180
|
+
params = {"ref": ref} if ref else None
|
|
181
|
+
data = await self._request(
|
|
182
|
+
f"repos/{repository}/contents/{encoded_path}", params
|
|
183
|
+
)
|
|
184
|
+
if not isinstance(data, dict):
|
|
185
|
+
raise ToolError("The requested GitHub path is not a file")
|
|
186
|
+
if data.get("encoding") != "base64" or not isinstance(data.get("content"), str):
|
|
187
|
+
raise ToolError("The requested file is binary or too large to read")
|
|
188
|
+
|
|
189
|
+
try:
|
|
190
|
+
content = base64.b64decode(data["content"], validate=False).decode("utf-8")
|
|
191
|
+
except (binascii.Error, UnicodeDecodeError) as exc:
|
|
192
|
+
raise ToolError("The requested file is not valid UTF-8 text") from exc
|
|
193
|
+
return limit(redact(content), self.settings.max_output_chars)
|
|
194
|
+
|
|
195
|
+
async def get_branch_sha(self, repository: str, branch: str) -> str:
|
|
196
|
+
repository = self.validate_repository(repository)
|
|
197
|
+
self._validate_branch_name(branch)
|
|
198
|
+
data = await self._request(
|
|
199
|
+
f"repos/{repository}/git/ref/heads/{quote(branch, safe='')}"
|
|
200
|
+
)
|
|
201
|
+
sha = (data.get("object") or {}).get("sha") if isinstance(data, dict) else None
|
|
202
|
+
if not isinstance(sha, str) or not sha:
|
|
203
|
+
raise ToolError("GitHub API returned an invalid branch reference")
|
|
204
|
+
return sha
|
|
205
|
+
|
|
206
|
+
async def get_default_branch(self, repository: str) -> str:
|
|
207
|
+
repository = self.validate_repository(repository)
|
|
208
|
+
data = await self._request(f"repos/{repository}")
|
|
209
|
+
default_branch = data.get("default_branch") if isinstance(data, dict) else None
|
|
210
|
+
if not isinstance(default_branch, str) or not default_branch:
|
|
211
|
+
raise ToolError("GitHub API returned invalid repository metadata")
|
|
212
|
+
return default_branch
|
|
213
|
+
|
|
214
|
+
async def validate_write_branch(
|
|
215
|
+
self, repository: str, branch: str, require_existing: bool = True
|
|
216
|
+
) -> str:
|
|
217
|
+
repository = self.validate_write_repository(repository)
|
|
218
|
+
self._validate_branch_name(branch)
|
|
219
|
+
prefix = self.settings.github_write_branch_prefix
|
|
220
|
+
if not branch.startswith(prefix):
|
|
221
|
+
raise PolicyDeniedError(f"Write branches must start with '{prefix}'")
|
|
222
|
+
if branch.casefold() == (await self.get_default_branch(repository)).casefold():
|
|
223
|
+
raise PolicyDeniedError(
|
|
224
|
+
"Writing to the repository default branch is denied"
|
|
225
|
+
)
|
|
226
|
+
if require_existing:
|
|
227
|
+
await self.get_branch_sha(repository, branch)
|
|
228
|
+
return branch
|
|
229
|
+
|
|
230
|
+
async def prepare_branch_creation(
|
|
231
|
+
self, repository: str, branch: str, base_branch: str
|
|
232
|
+
) -> dict[str, str]:
|
|
233
|
+
repository = self.validate_write_repository(repository)
|
|
234
|
+
self._validate_branch_name(branch)
|
|
235
|
+
if not branch.startswith(self.settings.github_write_branch_prefix):
|
|
236
|
+
raise PolicyDeniedError(
|
|
237
|
+
f"New branches must start with '{self.settings.github_write_branch_prefix}'"
|
|
238
|
+
)
|
|
239
|
+
self._validate_branch_name(base_branch)
|
|
240
|
+
base_sha = await self.get_branch_sha(repository, base_branch)
|
|
241
|
+
return {
|
|
242
|
+
"repository": repository,
|
|
243
|
+
"branch": branch,
|
|
244
|
+
"base_branch": base_branch,
|
|
245
|
+
"base_sha": base_sha,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async def create_branch(
|
|
249
|
+
self, repository: str, branch: str, base_sha: str
|
|
250
|
+
) -> dict[str, Any]:
|
|
251
|
+
repository = self.validate_write_repository(repository)
|
|
252
|
+
self._validate_branch_name(branch)
|
|
253
|
+
if not branch.startswith(self.settings.github_write_branch_prefix):
|
|
254
|
+
raise PolicyDeniedError(
|
|
255
|
+
f"New branches must start with '{self.settings.github_write_branch_prefix}'"
|
|
256
|
+
)
|
|
257
|
+
data = await self._write_request(
|
|
258
|
+
"POST",
|
|
259
|
+
f"repos/{repository}/git/refs",
|
|
260
|
+
{"ref": f"refs/heads/{branch}", "sha": base_sha},
|
|
261
|
+
)
|
|
262
|
+
return self._shape(
|
|
263
|
+
{
|
|
264
|
+
"ref": data.get("ref") if isinstance(data, dict) else None,
|
|
265
|
+
"sha": (
|
|
266
|
+
(data.get("object") or {}).get("sha")
|
|
267
|
+
if isinstance(data, dict)
|
|
268
|
+
else None
|
|
269
|
+
),
|
|
270
|
+
}
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
async def prepare_file_update(
|
|
274
|
+
self,
|
|
275
|
+
repository: str,
|
|
276
|
+
path: str,
|
|
277
|
+
branch: str,
|
|
278
|
+
content: str,
|
|
279
|
+
message: str,
|
|
280
|
+
) -> dict[str, Any]:
|
|
281
|
+
repository = self.validate_write_repository(repository)
|
|
282
|
+
path = self._validate_writable_file_path(path)
|
|
283
|
+
self._validate_blocked_path(path)
|
|
284
|
+
if self._is_sensitive_path(path):
|
|
285
|
+
raise PolicyDeniedError(
|
|
286
|
+
"Writes to sensitive credential files are blocked by policy"
|
|
287
|
+
)
|
|
288
|
+
if not message.strip() or len(message) > 200:
|
|
289
|
+
raise InvalidInputError("Commit message must contain 1 to 200 characters")
|
|
290
|
+
if redact(content) != content:
|
|
291
|
+
raise PolicyDeniedError(
|
|
292
|
+
"File content appears to contain a secret and cannot be staged"
|
|
293
|
+
)
|
|
294
|
+
if len(content) > max(1, self.settings.max_output_chars // 2):
|
|
295
|
+
raise InvalidInputError(
|
|
296
|
+
"File content is too large to show in the confirmation response"
|
|
297
|
+
)
|
|
298
|
+
await self.validate_write_branch(repository, branch)
|
|
299
|
+
|
|
300
|
+
encoded_path = quote(path, safe="/")
|
|
301
|
+
sha: str | None = None
|
|
302
|
+
try:
|
|
303
|
+
data = await self._request(
|
|
304
|
+
f"repos/{repository}/contents/{encoded_path}",
|
|
305
|
+
{"ref": branch},
|
|
306
|
+
)
|
|
307
|
+
sha = data.get("sha") if isinstance(data, dict) else None
|
|
308
|
+
if sha is not None and not isinstance(sha, str):
|
|
309
|
+
raise ToolError("GitHub API returned an invalid file reference")
|
|
310
|
+
except ToolError as exc:
|
|
311
|
+
if "not found or is not accessible" not in str(exc):
|
|
312
|
+
raise
|
|
313
|
+
|
|
314
|
+
return {"repository": repository, "path": path, "branch": branch, "sha": sha}
|
|
315
|
+
|
|
316
|
+
async def commit_file(
|
|
317
|
+
self,
|
|
318
|
+
repository: str,
|
|
319
|
+
path: str,
|
|
320
|
+
branch: str,
|
|
321
|
+
content: str,
|
|
322
|
+
message: str,
|
|
323
|
+
sha: str | None,
|
|
324
|
+
) -> dict[str, Any]:
|
|
325
|
+
repository = self.validate_write_repository(repository)
|
|
326
|
+
path = self._validate_writable_file_path(path)
|
|
327
|
+
self._validate_blocked_path(path)
|
|
328
|
+
if self._is_sensitive_path(path):
|
|
329
|
+
raise PolicyDeniedError(
|
|
330
|
+
"Writes to sensitive credential files are blocked by policy"
|
|
331
|
+
)
|
|
332
|
+
if redact(content) != content or redact(message) != message:
|
|
333
|
+
raise PolicyDeniedError(
|
|
334
|
+
"File content or commit message appears to contain a secret"
|
|
335
|
+
)
|
|
336
|
+
if not message.strip() or len(message) > 200:
|
|
337
|
+
raise InvalidInputError("Commit message must contain 1 to 200 characters")
|
|
338
|
+
if len(content) > max(1, self.settings.max_output_chars // 2):
|
|
339
|
+
raise InvalidInputError(
|
|
340
|
+
"File content is too large to show in the confirmation response"
|
|
341
|
+
)
|
|
342
|
+
await self.validate_write_branch(repository, branch)
|
|
343
|
+
payload: dict[str, Any] = {
|
|
344
|
+
"message": message,
|
|
345
|
+
"content": base64.b64encode(content.encode("utf-8")).decode("ascii"),
|
|
346
|
+
"branch": branch,
|
|
347
|
+
}
|
|
348
|
+
if sha:
|
|
349
|
+
payload["sha"] = sha
|
|
350
|
+
data = await self._write_request(
|
|
351
|
+
"PUT",
|
|
352
|
+
f"repos/{repository}/contents/{quote(path, safe='/')}",
|
|
353
|
+
payload,
|
|
354
|
+
)
|
|
355
|
+
commit = data.get("commit") if isinstance(data, dict) else {}
|
|
356
|
+
return self._shape(
|
|
357
|
+
{
|
|
358
|
+
"commit_sha": (commit or {}).get("sha"),
|
|
359
|
+
"html_url": (commit or {}).get("html_url"),
|
|
360
|
+
"content_path": (
|
|
361
|
+
(data.get("content") or {}).get("path")
|
|
362
|
+
if isinstance(data, dict)
|
|
363
|
+
else None
|
|
364
|
+
),
|
|
365
|
+
"branch": branch,
|
|
366
|
+
}
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
async def create_pull_request(
|
|
370
|
+
self,
|
|
371
|
+
repository: str,
|
|
372
|
+
head: str,
|
|
373
|
+
base: str,
|
|
374
|
+
title: str,
|
|
375
|
+
body: str,
|
|
376
|
+
) -> dict[str, Any]:
|
|
377
|
+
repository = self.validate_write_repository(repository)
|
|
378
|
+
if not title.strip() or len(title) > 256:
|
|
379
|
+
raise InvalidInputError(
|
|
380
|
+
"Pull request title must contain 1 to 256 characters"
|
|
381
|
+
)
|
|
382
|
+
if len(body) > self.settings.max_output_chars // 2:
|
|
383
|
+
raise InvalidInputError(
|
|
384
|
+
"Pull request body is too large to show in the confirmation response"
|
|
385
|
+
)
|
|
386
|
+
if redact(title) != title or redact(body) != body:
|
|
387
|
+
raise PolicyDeniedError("Pull request details appear to contain a secret")
|
|
388
|
+
await self.validate_write_branch(repository, head)
|
|
389
|
+
await self.get_branch_sha(repository, base)
|
|
390
|
+
data = await self._write_request(
|
|
391
|
+
"POST",
|
|
392
|
+
f"repos/{repository}/pulls",
|
|
393
|
+
{"title": title, "head": head, "base": base, "body": body},
|
|
394
|
+
)
|
|
395
|
+
return self._shape(
|
|
396
|
+
{
|
|
397
|
+
"number": data.get("number") if isinstance(data, dict) else None,
|
|
398
|
+
"html_url": data.get("html_url") if isinstance(data, dict) else None,
|
|
399
|
+
"state": data.get("state") if isinstance(data, dict) else None,
|
|
400
|
+
}
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
async def list_commits(
|
|
404
|
+
self, repository: str, branch: str | None, limit_count: int
|
|
405
|
+
) -> list[dict[str, Any]]:
|
|
406
|
+
repository = self.validate_repository(repository)
|
|
407
|
+
params: dict[str, Any] = {}
|
|
408
|
+
if branch:
|
|
409
|
+
params["sha"] = branch
|
|
410
|
+
data = await self._paginate(f"repos/{repository}/commits", params)
|
|
411
|
+
return [
|
|
412
|
+
self._shape(
|
|
413
|
+
{
|
|
414
|
+
"sha": item.get("sha"),
|
|
415
|
+
"message": (item.get("commit") or {}).get("message"),
|
|
416
|
+
"author": ((item.get("commit") or {}).get("author") or {}).get(
|
|
417
|
+
"name"
|
|
418
|
+
),
|
|
419
|
+
"date": ((item.get("commit") or {}).get("author") or {}).get(
|
|
420
|
+
"date"
|
|
421
|
+
),
|
|
422
|
+
"html_url": item.get("html_url"),
|
|
423
|
+
}
|
|
424
|
+
)
|
|
425
|
+
for item in data[:limit_count]
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
async def list_issues(
|
|
429
|
+
self, repository: str, state: str, limit_count: int
|
|
430
|
+
) -> list[dict[str, Any]]:
|
|
431
|
+
repository = self.validate_repository(repository)
|
|
432
|
+
data = await self._paginate(
|
|
433
|
+
f"repos/{repository}/issues", {"state": state, "sort": "updated"}
|
|
434
|
+
)
|
|
435
|
+
issues = [item for item in data if "pull_request" not in item]
|
|
436
|
+
return [
|
|
437
|
+
self._shape(
|
|
438
|
+
{
|
|
439
|
+
"number": item.get("number"),
|
|
440
|
+
"title": item.get("title"),
|
|
441
|
+
"state": item.get("state"),
|
|
442
|
+
"user": (item.get("user") or {}).get("login"),
|
|
443
|
+
"created_at": item.get("created_at"),
|
|
444
|
+
"html_url": item.get("html_url"),
|
|
445
|
+
}
|
|
446
|
+
)
|
|
447
|
+
for item in issues[:limit_count]
|
|
448
|
+
]
|
|
449
|
+
|
|
450
|
+
async def list_pull_requests(
|
|
451
|
+
self, repository: str, state: str, limit_count: int
|
|
452
|
+
) -> list[dict[str, Any]]:
|
|
453
|
+
repository = self.validate_repository(repository)
|
|
454
|
+
data = await self._paginate(
|
|
455
|
+
f"repos/{repository}/pulls", {"state": state, "sort": "updated"}
|
|
456
|
+
)
|
|
457
|
+
return [
|
|
458
|
+
self._shape(
|
|
459
|
+
{
|
|
460
|
+
"number": item.get("number"),
|
|
461
|
+
"title": item.get("title"),
|
|
462
|
+
"state": item.get("state"),
|
|
463
|
+
"user": (item.get("user") or {}).get("login"),
|
|
464
|
+
"head_branch": (item.get("head") or {}).get("ref"),
|
|
465
|
+
"base_branch": (item.get("base") or {}).get("ref"),
|
|
466
|
+
"created_at": item.get("created_at"),
|
|
467
|
+
"html_url": item.get("html_url"),
|
|
468
|
+
}
|
|
469
|
+
)
|
|
470
|
+
for item in data[:limit_count]
|
|
471
|
+
]
|
|
472
|
+
|
|
473
|
+
@staticmethod
|
|
474
|
+
def _shape(data: dict[str, Any]) -> dict[str, Any]:
|
|
475
|
+
return {
|
|
476
|
+
key: redact(value) if isinstance(value, str) else value
|
|
477
|
+
for key, value in data.items()
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
@staticmethod
|
|
481
|
+
def _validate_path(path: str, allow_empty: bool) -> str:
|
|
482
|
+
normalized = path.strip("/")
|
|
483
|
+
if not normalized and not allow_empty:
|
|
484
|
+
raise InvalidInputError("A file path is required")
|
|
485
|
+
if (
|
|
486
|
+
"\x00" in path
|
|
487
|
+
or "\\" in path
|
|
488
|
+
or any(part in {".", ".."} for part in normalized.split("/"))
|
|
489
|
+
):
|
|
490
|
+
raise InvalidInputError("Invalid GitHub path")
|
|
491
|
+
return normalized
|
|
492
|
+
|
|
493
|
+
@staticmethod
|
|
494
|
+
def _validate_branch_name(branch: str) -> None:
|
|
495
|
+
if (
|
|
496
|
+
not branch
|
|
497
|
+
or len(branch) > 240
|
|
498
|
+
or branch.startswith(("/", "."))
|
|
499
|
+
or branch.endswith(("/", "."))
|
|
500
|
+
or ".." in branch
|
|
501
|
+
or "@{" in branch
|
|
502
|
+
or "//" in branch
|
|
503
|
+
or any(char.isspace() or char in "~^:?*[\\" for char in branch)
|
|
504
|
+
or any(part.endswith(".lock") for part in branch.split("/"))
|
|
505
|
+
):
|
|
506
|
+
raise InvalidInputError("Invalid Git branch name")
|
|
507
|
+
|
|
508
|
+
@classmethod
|
|
509
|
+
def _validate_writable_file_path(cls, path: str) -> str:
|
|
510
|
+
if path.startswith("/") or path.endswith("/") or "//" in path:
|
|
511
|
+
raise InvalidInputError(
|
|
512
|
+
"Write path must be a normalized repository-relative file path"
|
|
513
|
+
)
|
|
514
|
+
return cls._validate_path(path, allow_empty=False)
|
|
515
|
+
|
|
516
|
+
def _validate_blocked_path(self, path: str) -> None:
|
|
517
|
+
normalized = path.casefold()
|
|
518
|
+
blocked = {".github/workflows"} | {
|
|
519
|
+
item.casefold().strip("/") for item in self.settings.blocked_github_paths
|
|
520
|
+
}
|
|
521
|
+
if any(
|
|
522
|
+
normalized == item or normalized.startswith(f"{item}/") for item in blocked
|
|
523
|
+
):
|
|
524
|
+
raise PolicyDeniedError("Writes to this GitHub path are blocked by policy")
|
|
525
|
+
|
|
526
|
+
@staticmethod
|
|
527
|
+
def _is_sensitive_path(path: str) -> bool:
|
|
528
|
+
name = path.rsplit("/", 1)[-1].casefold()
|
|
529
|
+
return (
|
|
530
|
+
name == ".env"
|
|
531
|
+
or name.startswith(".env.")
|
|
532
|
+
or name.endswith((".pem", ".key", ".p12", ".pfx"))
|
|
533
|
+
or name in {"id_rsa", "id_ed25519"}
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
def validate_write_repository(self, repository: str) -> str:
|
|
537
|
+
repository = self.validate_repository(repository)
|
|
538
|
+
if not self.settings.allowed_github_repos:
|
|
539
|
+
raise PolicyDeniedError(
|
|
540
|
+
"GitHub writes require an explicit repository allowlist"
|
|
541
|
+
)
|
|
542
|
+
return repository
|
|
543
|
+
|
|
544
|
+
async def _write_request(
|
|
545
|
+
self, method: str, path: str, payload: dict[str, Any]
|
|
546
|
+
) -> Any:
|
|
547
|
+
self.require_write_token()
|
|
548
|
+
token = self.settings.github_write_token
|
|
549
|
+
try:
|
|
550
|
+
async with httpx.AsyncClient(
|
|
551
|
+
timeout=10.0, follow_redirects=False
|
|
552
|
+
) as client:
|
|
553
|
+
response = await client.request(
|
|
554
|
+
method,
|
|
555
|
+
f"{self.base_url}/{path.lstrip('/')}",
|
|
556
|
+
headers={
|
|
557
|
+
"Accept": "application/vnd.github+json",
|
|
558
|
+
"Authorization": f"Bearer {token}",
|
|
559
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
560
|
+
},
|
|
561
|
+
json=payload,
|
|
562
|
+
)
|
|
563
|
+
except httpx.TimeoutException as exc:
|
|
564
|
+
raise ToolError("GitHub API request timed out") from exc
|
|
565
|
+
except httpx.RequestError as exc:
|
|
566
|
+
raise ToolError("GitHub API is unavailable") from exc
|
|
567
|
+
|
|
568
|
+
if response.status_code == 401:
|
|
569
|
+
raise ToolError("GitHub rejected the configured write token")
|
|
570
|
+
if response.status_code == 403:
|
|
571
|
+
if response.headers.get(
|
|
572
|
+
"X-RateLimit-Remaining"
|
|
573
|
+
) == "0" or response.headers.get("Retry-After"):
|
|
574
|
+
raise ToolError("GitHub API rate limit exceeded; try again later")
|
|
575
|
+
raise ToolError("GitHub denied the write; check write token permissions")
|
|
576
|
+
if response.status_code == 404:
|
|
577
|
+
raise ToolError("GitHub resource was not found or is not accessible")
|
|
578
|
+
if response.status_code == 429:
|
|
579
|
+
raise ToolError("GitHub API rate limit exceeded; try again later")
|
|
580
|
+
if response.is_error:
|
|
581
|
+
raise ToolError(f"GitHub API returned HTTP {response.status_code}")
|
|
582
|
+
if not response.content:
|
|
583
|
+
return {}
|
|
584
|
+
try:
|
|
585
|
+
return response.json()
|
|
586
|
+
except ValueError as exc:
|
|
587
|
+
raise ToolError("GitHub API returned an invalid response") from exc
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""MCP tool registrations."""
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from fastmcp import FastMCP
|
|
6
|
+
|
|
7
|
+
from ..audit import audit_tool
|
|
8
|
+
from ..security import limit
|
|
9
|
+
from ..services.docker_client import DockerClient
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _json_result(data: Any, max_chars: int) -> str:
|
|
13
|
+
return limit(json.dumps(data, ensure_ascii=True, separators=(",", ":")), max_chars)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def register(mcp: FastMCP, client: DockerClient) -> None:
|
|
17
|
+
@mcp.tool
|
|
18
|
+
@audit_tool
|
|
19
|
+
async def docker_list_containers(
|
|
20
|
+
include_stopped: bool = False, limit: int = 50
|
|
21
|
+
) -> str:
|
|
22
|
+
"""List local containers; results are restricted to the configured container allowlist when set."""
|
|
23
|
+
containers = await asyncio.to_thread(
|
|
24
|
+
client.list_containers, include_stopped, limit
|
|
25
|
+
)
|
|
26
|
+
return _json_result(containers, client.settings.max_output_chars)
|
|
27
|
+
|
|
28
|
+
@mcp.tool
|
|
29
|
+
@audit_tool
|
|
30
|
+
async def docker_list_images(limit: int = 50) -> str:
|
|
31
|
+
"""List local Docker images with a bounded result count."""
|
|
32
|
+
images = await asyncio.to_thread(client.list_images, limit)
|
|
33
|
+
return _json_result(images, client.settings.max_output_chars)
|
|
34
|
+
|
|
35
|
+
@mcp.tool
|
|
36
|
+
@audit_tool
|
|
37
|
+
async def docker_container_status(name: str) -> str:
|
|
38
|
+
"""Return selected status metadata for an allowlisted container."""
|
|
39
|
+
return _json_result(
|
|
40
|
+
await asyncio.to_thread(client.container_status, name),
|
|
41
|
+
client.settings.max_output_chars,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
@mcp.tool
|
|
45
|
+
@audit_tool
|
|
46
|
+
async def docker_container_logs(name: str, tail: int = 100) -> str:
|
|
47
|
+
"""Return a bounded and redacted tail of logs from an allowlisted container."""
|
|
48
|
+
return await asyncio.to_thread(client.container_logs, name, tail)
|
|
49
|
+
|
|
50
|
+
@mcp.tool
|
|
51
|
+
@audit_tool
|
|
52
|
+
async def docker_inspect_container(name: str) -> str:
|
|
53
|
+
"""Return filtered container metadata without environment variables or host source paths."""
|
|
54
|
+
return _json_result(
|
|
55
|
+
await asyncio.to_thread(client.inspect_container, name),
|
|
56
|
+
client.settings.max_output_chars,
|
|
57
|
+
)
|