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.
@@ -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
+ )