mxready 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.
Files changed (71) hide show
  1. mxready/__init__.py +1 -0
  2. mxready/api/__init__.py +4 -0
  3. mxready/api/middleware.py +149 -0
  4. mxready/api/rules.py +10 -0
  5. mxready/api/scans.py +164 -0
  6. mxready/api/workspace.py +156 -0
  7. mxready/app.py +179 -0
  8. mxready/cli/__init__.py +1 -0
  9. mxready/cli/build_verification_bundle.py +45 -0
  10. mxready/cli/generate_public_report.py +140 -0
  11. mxready/cli/scan_batch.py +368 -0
  12. mxready/cli/scan_repository.py +212 -0
  13. mxready/cli/serve.py +55 -0
  14. mxready/config.py +117 -0
  15. mxready/errors.py +11 -0
  16. mxready/logging_setup.py +88 -0
  17. mxready/mcp/__init__.py +5 -0
  18. mxready/mcp/server.py +433 -0
  19. mxready/models.py +382 -0
  20. mxready/persistence/__init__.py +1 -0
  21. mxready/persistence/sqlite.py +581 -0
  22. mxready/py.typed +0 -0
  23. mxready/reporting/__init__.py +4 -0
  24. mxready/reporting/badge.py +40 -0
  25. mxready/reporting/comparison.py +33 -0
  26. mxready/reporting/markdown.py +157 -0
  27. mxready/reporting/sarif.py +94 -0
  28. mxready/reporting/terminal.py +75 -0
  29. mxready/repository/__init__.py +14 -0
  30. mxready/repository/git_client.py +275 -0
  31. mxready/repository/github_archive.py +566 -0
  32. mxready/repository/identity.py +140 -0
  33. mxready/resources/__init__.py +19 -0
  34. mxready/resources/frontend/assets/index-BxJEwNUm.css +1 -0
  35. mxready/resources/frontend/assets/index-lzTCdWAt.js +9 -0
  36. mxready/resources/frontend/assets/mx-chip-C0HgFQyE.webp +0 -0
  37. mxready/resources/frontend/index.html +17 -0
  38. mxready/resources/rules/v1/core.yml +634 -0
  39. mxready/resources/rules/v1/manifest.yml +4 -0
  40. mxready/resources/schemas/verification-result-v1.json +259 -0
  41. mxready/scanning/__init__.py +17 -0
  42. mxready/scanning/analyzer.py +183 -0
  43. mxready/scanning/facts.py +596 -0
  44. mxready/scanning/indexer.py +299 -0
  45. mxready/scanning/path_context.py +174 -0
  46. mxready/scanning/python_source.py +67 -0
  47. mxready/scanning/rule_engine.py +595 -0
  48. mxready/scanning/rule_loader.py +282 -0
  49. mxready/scanning/toolchain.py +100 -0
  50. mxready/services/__init__.py +3 -0
  51. mxready/services/scans.py +239 -0
  52. mxready/services/worker.py +38 -0
  53. mxready/verification/__init__.py +11 -0
  54. mxready/verification/bundle.py +137 -0
  55. mxready/verification/validation.py +101 -0
  56. mxready-1.0.0.dist-info/METADATA +526 -0
  57. mxready-1.0.0.dist-info/RECORD +71 -0
  58. mxready-1.0.0.dist-info/WHEEL +5 -0
  59. mxready-1.0.0.dist-info/entry_points.txt +8 -0
  60. mxready-1.0.0.dist-info/licenses/LICENSE +201 -0
  61. mxready-1.0.0.dist-info/top_level.txt +2 -0
  62. mxready_runner/__init__.py +1 -0
  63. mxready_runner/__main__.py +3 -0
  64. mxready_runner/cli.py +88 -0
  65. mxready_runner/contracts.py +26 -0
  66. mxready_runner/execute.py +185 -0
  67. mxready_runner/inspect.py +79 -0
  68. mxready_runner/process.py +103 -0
  69. mxready_runner/py.typed +0 -0
  70. mxready_runner/redact.py +36 -0
  71. mxready_runner/schema.py +326 -0
@@ -0,0 +1,212 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import re
5
+ import subprocess
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from uuid import NAMESPACE_URL, uuid5
10
+
11
+ from mxready.config import Settings
12
+ from mxready.errors import MxReadyError
13
+ from mxready.models import ScanReport
14
+ from mxready.reporting.badge import render_badge
15
+ from mxready.reporting.comparison import new_blockers
16
+ from mxready.reporting.markdown import render_markdown
17
+ from mxready.reporting.sarif import render_sarif
18
+ from mxready.reporting.terminal import render_terminal_summary
19
+ from mxready.repository.identity import parse_repository_url
20
+ from mxready.scanning.analyzer import ScanAnalyzer
21
+ from mxready.scanning.rule_loader import load_rule_catalog
22
+
23
+ _COMMIT_PATTERN = re.compile(r"^[0-9a-f]{40}$")
24
+ _SCP_LIKE_REMOTE = re.compile(
25
+ r"^(?:[^@/:]+@)?(?P<host>[A-Za-z0-9._-]*\.[A-Za-z0-9-]+):(?P<path>[A-Za-z0-9._~/-]+)$"
26
+ )
27
+ _SSH_URL_REMOTE = re.compile(
28
+ r"^ssh://(?:[^@/]+@)?(?P<host>[A-Za-z0-9._-]+)/(?P<path>[A-Za-z0-9._~/-]+)$"
29
+ )
30
+
31
+
32
+ def main(argv: Sequence[str] | None = None) -> int:
33
+ arguments = _build_parser().parse_args(argv)
34
+ try:
35
+ repository_root = _local_repository(arguments.local_path)
36
+ commit = _commit_sha(arguments.commit or _detect_commit(repository_root))
37
+ identity = parse_repository_url(
38
+ arguments.repo_url or _detect_repository_url(repository_root)
39
+ )
40
+ settings = Settings.from_env()
41
+ report = ScanAnalyzer(
42
+ load_rule_catalog(
43
+ settings.rules_dir,
44
+ extra_directory=settings.extra_rules_dir,
45
+ ),
46
+ enable_prefilter=settings.prefilter_enabled,
47
+ ).analyze(
48
+ repository_root,
49
+ repository_url=identity.clone_url,
50
+ commit=commit,
51
+ scan_id=uuid5(NAMESPACE_URL, f"{identity.clone_url}@{commit}"),
52
+ stage_callback=lambda status: None,
53
+ )
54
+ output_dir = Path(arguments.output)
55
+ output_dir.mkdir(parents=True, exist_ok=True)
56
+ stem = f"{identity.name}-{commit[:12]}"
57
+ _write_outputs(output_dir, stem, report)
58
+ blockers = [f for f in report.findings if f.severity.value == "blocker"]
59
+ if arguments.fail_on == "new-blockers":
60
+ if arguments.baseline is None:
61
+ raise ValueError("--baseline is required with --fail-on new-blockers")
62
+ baseline_path = Path(arguments.baseline)
63
+ if baseline_path.stat().st_size > 20 * 1024 * 1024:
64
+ raise ValueError("Baseline exceeds 20 MiB")
65
+ baseline = ScanReport.model_validate_json(baseline_path.read_bytes())
66
+ blockers = new_blockers(baseline, report)
67
+ print(f"New or increased blocker groups: {len(blockers)}")
68
+ elif arguments.baseline is not None:
69
+ raise ValueError("--baseline requires --fail-on new-blockers")
70
+ except (MxReadyError, OSError, ValueError) as error:
71
+ print(f"MXReady scan error: {error}", file=sys.stderr)
72
+ return 1
73
+
74
+ print(
75
+ _terminal_summary(
76
+ report, output_note=f"{stem}.json / {stem}.md / {stem}.svg / {stem}.sarif"
77
+ )
78
+ )
79
+ return 2 if blockers else 0
80
+
81
+
82
+ def _terminal_summary(report: ScanReport, *, output_note: str) -> str:
83
+ """按标准输出编码选取摘要语言;旧代码页无法编码中文时回退纯 ASCII。"""
84
+ summary = render_terminal_summary(report, output_note=output_note)
85
+ if _can_encode(summary):
86
+ return summary
87
+ summary = render_terminal_summary(report, output_note=output_note, english=True)
88
+ if _can_encode(summary):
89
+ return summary
90
+ encoding = getattr(sys.stdout, "encoding", None) or "ascii"
91
+ return summary.encode(encoding, errors="backslashreplace").decode(encoding)
92
+
93
+
94
+ def _can_encode(text: str) -> bool:
95
+ encoding = getattr(sys.stdout, "encoding", None)
96
+ if not encoding:
97
+ return True
98
+ try:
99
+ text.encode(encoding)
100
+ except (LookupError, UnicodeEncodeError):
101
+ return False
102
+ return True
103
+
104
+
105
+ def _build_parser() -> argparse.ArgumentParser:
106
+ parser = argparse.ArgumentParser(
107
+ prog="mxready-scan",
108
+ description="Generate MXReady reports from a trusted local source directory.",
109
+ )
110
+ parser.add_argument("local_path", help="Local repository directory to inspect")
111
+ parser.add_argument(
112
+ "--repo-url",
113
+ help="Canonical public GitHub/Gitee URL; defaults to the local origin remote",
114
+ )
115
+ parser.add_argument(
116
+ "--commit",
117
+ help="Resolved 40-character Git commit SHA; defaults to the local HEAD",
118
+ )
119
+ parser.add_argument(
120
+ "--output", required=True, help="Directory for JSON, Markdown, SVG, and SARIF"
121
+ )
122
+ parser.add_argument("--baseline", type=Path, help="Trusted baseline report JSON")
123
+ parser.add_argument(
124
+ "--fail-on", choices=("all-blockers", "new-blockers"), default="all-blockers"
125
+ )
126
+ return parser
127
+
128
+
129
+ def _local_repository(value: str) -> Path:
130
+ path = Path(value)
131
+ if not path.exists():
132
+ raise ValueError(f"Local repository directory does not exist: {path}")
133
+ if not path.is_dir():
134
+ raise ValueError(f"Local repository path is not a directory: {path}")
135
+ return path.resolve()
136
+
137
+
138
+ def _commit_sha(value: str) -> str:
139
+ if not _COMMIT_PATTERN.fullmatch(value):
140
+ raise ValueError("Commit must be a 40-character lowercase hexadecimal SHA.")
141
+ return value
142
+
143
+
144
+ def _run_git(repository_root: Path, *arguments: str) -> str:
145
+ """在只读模式下询问本地仓库;git 不可用或命令失败时返回空字符串。"""
146
+ try:
147
+ completed = subprocess.run(
148
+ ("git", "-C", str(repository_root), *arguments),
149
+ capture_output=True,
150
+ check=False,
151
+ encoding="utf-8",
152
+ errors="replace",
153
+ shell=False,
154
+ text=True,
155
+ timeout=10,
156
+ )
157
+ except (OSError, subprocess.SubprocessError):
158
+ return ""
159
+ return completed.stdout.strip() if completed.returncode == 0 else ""
160
+
161
+
162
+ def _detect_commit(repository_root: Path) -> str:
163
+ commit = _run_git(repository_root, "rev-parse", "HEAD")
164
+ if not commit:
165
+ raise ValueError(
166
+ "Cannot resolve the local HEAD (git rev-parse HEAD failed); "
167
+ "pass --commit with a 40-character commit SHA."
168
+ )
169
+ return commit
170
+
171
+
172
+ def _detect_repository_url(repository_root: Path) -> str:
173
+ remote = _run_git(repository_root, "remote", "get-url", "origin")
174
+ normalized = _normalize_remote_url(remote)
175
+ if not normalized.startswith("https://"):
176
+ raise ValueError(
177
+ "Cannot resolve a public origin remote (git remote get-url origin failed); "
178
+ "pass --repo-url with a GitHub/Gitee HTTPS URL."
179
+ )
180
+ return normalized
181
+
182
+
183
+ def _normalize_remote_url(value: str) -> str:
184
+ """把 SSH 形式的 origin 地址转成 parse_repository_url 接受的 HTTPS 形式。"""
185
+ candidate = value.strip()
186
+ if "://" in candidate:
187
+ match = _SSH_URL_REMOTE.fullmatch(candidate) if candidate.startswith("ssh://") else None
188
+ else:
189
+ match = _SCP_LIKE_REMOTE.fullmatch(candidate)
190
+ if match is None:
191
+ return candidate
192
+ return f"https://{match.group('host')}/{match.group('path')}"
193
+
194
+
195
+ def _write_outputs(output_dir: Path, stem: str, report: ScanReport) -> None:
196
+ (output_dir / f"{stem}.sarif").write_text(render_sarif(report), encoding="utf-8")
197
+ (output_dir / f"{stem}.json").write_text(
198
+ report.model_dump_json(indent=2) + "\n",
199
+ encoding="utf-8",
200
+ )
201
+ (output_dir / f"{stem}.md").write_text(
202
+ render_markdown(report),
203
+ encoding="utf-8",
204
+ )
205
+ (output_dir / f"{stem}.svg").write_text(
206
+ render_badge(report),
207
+ encoding="utf-8",
208
+ )
209
+
210
+
211
+ if __name__ == "__main__":
212
+ raise SystemExit(main())
mxready/cli/serve.py ADDED
@@ -0,0 +1,55 @@
1
+ """Start the installed Web application with persistent per-user data."""
2
+
3
+ import argparse
4
+ import os
5
+ import shutil
6
+ import sys
7
+ from dataclasses import replace
8
+ from pathlib import Path
9
+
10
+ import uvicorn
11
+
12
+ from mxready.app import create_app
13
+ from mxready.config import Settings
14
+
15
+
16
+ def default_data_directory() -> Path:
17
+ if sys.platform == "win32":
18
+ return Path(os.getenv("LOCALAPPDATA", str(Path.home() / "AppData" / "Local"))) / "MXReady"
19
+ if sys.platform == "darwin":
20
+ return Path.home() / "Library" / "Application Support" / "MXReady"
21
+ return Path(os.getenv("XDG_DATA_HOME", str(Path.home() / ".local" / "share"))) / "mxready"
22
+
23
+
24
+ def _port(value: str) -> int:
25
+ number = int(value)
26
+ if not 1 <= number <= 65535:
27
+ raise argparse.ArgumentTypeError("Port must be between 1 and 65535")
28
+ return number
29
+
30
+
31
+ def main(argv: list[str] | None = None) -> int:
32
+ parser = argparse.ArgumentParser(description=__doc__)
33
+ parser.add_argument("--host", default="127.0.0.1")
34
+ parser.add_argument("--port", type=_port, default=8000)
35
+ parser.add_argument("--data-dir", type=Path)
36
+ args = parser.parse_args(argv)
37
+ if not shutil.which("git"):
38
+ print(
39
+ "Git is required. Install Git and add it to PATH before starting MXReady.",
40
+ file=sys.stderr,
41
+ )
42
+ return 1
43
+ settings = Settings.from_env()
44
+ data_dir = args.data_dir or Path(os.getenv("MXREADY_DATA_DIR", str(default_data_directory())))
45
+ settings = replace(
46
+ settings,
47
+ data_dir=data_dir,
48
+ temp_dir=Path(os.getenv("MXREADY_TEMP_DIR", str(data_dir / "tmp"))),
49
+ )
50
+ uvicorn.run(create_app(settings), host=args.host, port=args.port)
51
+ return 0
52
+
53
+
54
+ if __name__ == "__main__":
55
+ raise SystemExit(main())
mxready/config.py ADDED
@@ -0,0 +1,117 @@
1
+ """应用配置:默认值 + 环境变量注入(12-factor)。
2
+
3
+ 所有可配置项均支持通过 `MXREADY_*` 环境变量覆盖,未设置时使用内置默认值。
4
+ 参见仓库根目录 `.env.example` 与 `docs/deployment.md`。
5
+ """
6
+
7
+ import os
8
+ from dataclasses import dataclass, field
9
+ from pathlib import Path
10
+
11
+ from mxready.resources import default_frontend_directory, default_rules_directory
12
+
13
+ _ENV_PREFIX = "MXREADY_"
14
+
15
+
16
+ def _path_env(name: str, default: str) -> Path:
17
+ return Path(os.getenv(_ENV_PREFIX + name, default))
18
+
19
+
20
+ def _optional_path_env(name: str) -> Path | None:
21
+ raw = os.getenv(_ENV_PREFIX + name)
22
+ if raw is None or not raw.strip():
23
+ return None
24
+ return Path(raw.strip())
25
+
26
+
27
+ def _int_env(name: str, default: int) -> int:
28
+ try:
29
+ return int(os.getenv(_ENV_PREFIX + name, str(default)))
30
+ except ValueError:
31
+ return default
32
+
33
+
34
+ def _bool_env(name: str, default: bool) -> bool:
35
+ raw = os.getenv(_ENV_PREFIX + name)
36
+ if raw is None:
37
+ return default
38
+ return raw.strip().casefold() in {"1", "true", "yes", "on"}
39
+
40
+
41
+ def _hosts_env(name: str, default: tuple[str, ...]) -> tuple[str, ...]:
42
+ raw = os.getenv(_ENV_PREFIX + name)
43
+ if raw is None or not raw.strip():
44
+ return default
45
+ return tuple(host.strip() for host in raw.split(",") if host.strip())
46
+
47
+
48
+ # 预过滤开关的合法取值:auto 为默认启用,off 为应急止血(回退全量扫描,
49
+ # 结果集完全不变,只影响耗时)。
50
+ _PREFILTER_MODES = ("auto", "off")
51
+
52
+
53
+ def _prefilter_mode_env(name: str, default: str = "auto") -> str:
54
+ """取值非法时回退默认值:拼错不该让服务起不来,也不该静默关掉优化。"""
55
+ raw = os.getenv(_ENV_PREFIX + name)
56
+ if raw is None or not raw.strip():
57
+ return default
58
+ mode = raw.strip().casefold()
59
+ return mode if mode in _PREFILTER_MODES else default
60
+
61
+
62
+ @dataclass(frozen=True, slots=True)
63
+ class Settings:
64
+ # 路径
65
+ data_dir: Path = Path("data")
66
+ rules_dir: Path = default_rules_directory()
67
+ extra_rules_dir: Path | None = None
68
+ temp_dir: Path = Path("data/tmp")
69
+ frontend_dist: Path = field(default_factory=default_frontend_directory)
70
+ # 日志
71
+ log_level: str = "INFO"
72
+ # 托管平台白名单(保持安全默认值)
73
+ allowed_hosts: tuple[str, ...] = ("github.com", "gitee.com")
74
+ # 数据保留(0 = 不自动清理,默认关闭以保护申报证据)
75
+ scan_retention_days: int = 0
76
+ # 防护(默认关闭限流,公网部署时按需开启)
77
+ rate_limit_enabled: bool = False
78
+ rate_limit_per_minute: int = 20
79
+ max_concurrent_scans: int = 2
80
+ max_request_bytes: int = 1_048_576
81
+ scan_worker_threads: int = 2
82
+ task_lease_seconds: int = 120
83
+ # 规则正则的必需字面量预过滤:auto 启用;off 关闭(应急止血,结论不变)
84
+ prefilter_mode: str = "auto"
85
+
86
+ def __post_init__(self) -> None:
87
+ if self.scan_worker_threads < 1 or self.task_lease_seconds < 3:
88
+ raise ValueError("Worker threads must be positive and task leases at least 3 seconds")
89
+ if self.max_concurrent_scans < 0 or self.max_request_bytes < 1:
90
+ raise ValueError("Invalid scan concurrency or request size limit")
91
+ if self.prefilter_mode not in _PREFILTER_MODES:
92
+ raise ValueError("prefilter_mode must be 'auto' or 'off'")
93
+
94
+ @property
95
+ def prefilter_enabled(self) -> bool:
96
+ return self.prefilter_mode != "off"
97
+
98
+ @classmethod
99
+ def from_env(cls) -> "Settings":
100
+ """读取 `MXREADY_*` 环境变量构造配置,未设置项使用默认值。"""
101
+ return cls(
102
+ data_dir=_path_env("DATA_DIR", "data"),
103
+ rules_dir=_path_env("RULES_DIR", str(default_rules_directory())),
104
+ extra_rules_dir=_optional_path_env("EXTRA_RULES_DIR"),
105
+ temp_dir=_path_env("TEMP_DIR", "data/tmp"),
106
+ frontend_dist=_path_env("FRONTEND_DIR", str(default_frontend_directory())),
107
+ log_level=os.getenv(_ENV_PREFIX + "LOG_LEVEL", "INFO"),
108
+ allowed_hosts=_hosts_env("ALLOWED_HOSTS", ("github.com", "gitee.com")),
109
+ scan_retention_days=_int_env("SCAN_RETENTION_DAYS", 0),
110
+ rate_limit_enabled=_bool_env("RATE_LIMIT_ENABLED", False),
111
+ rate_limit_per_minute=_int_env("RATE_LIMIT_PER_MINUTE", 20),
112
+ max_concurrent_scans=_int_env("MAX_CONCURRENT_SCANS", 2),
113
+ max_request_bytes=_int_env("MAX_REQUEST_BYTES", 1_048_576),
114
+ scan_worker_threads=_int_env("SCAN_WORKER_THREADS", 2),
115
+ task_lease_seconds=_int_env("TASK_LEASE_SECONDS", 120),
116
+ prefilter_mode=_prefilter_mode_env("PREFILTER_MODE"),
117
+ )
mxready/errors.py ADDED
@@ -0,0 +1,11 @@
1
+ class MxReadyError(Exception):
2
+ def __init__(
3
+ self,
4
+ code: str,
5
+ message: str,
6
+ details: dict[str, str] | None = None,
7
+ ) -> None:
8
+ super().__init__(message)
9
+ self.code = code
10
+ self.message = message
11
+ self.details = details or {}
@@ -0,0 +1,88 @@
1
+ """统一日志配置:JSON 结构化输出 + 敏感信息脱敏。
2
+
3
+ 只配置 `mxready` 命名空间的 logger;uvicorn 自身与访问日志保持默认,
4
+ 避免重复输出与格式不一致。脱敏规则与 `src/mxready_runner/redact.py` 对齐。
5
+ """
6
+
7
+ import json
8
+ import logging
9
+ import re
10
+ import sys
11
+ from datetime import UTC, datetime
12
+ from typing import Any, Final
13
+
14
+ _LOGGER_NAME: Final = "mxready"
15
+
16
+ # stdlib LogRecord 自带的属性不进 JSON 字段
17
+ _SKIP_ATTRS: Final = frozenset(
18
+ {
19
+ "name",
20
+ "msg",
21
+ "args",
22
+ "levelname",
23
+ "levelno",
24
+ "pathname",
25
+ "filename",
26
+ "module",
27
+ "exc_info",
28
+ "exc_text",
29
+ "stack_info",
30
+ "lineno",
31
+ "funcName",
32
+ "created",
33
+ "msecs",
34
+ "relativeCreated",
35
+ "thread",
36
+ "threadName",
37
+ "processName",
38
+ "process",
39
+ "taskName",
40
+ "message",
41
+ "asctime",
42
+ }
43
+ )
44
+
45
+ _URL_CREDENTIALS = re.compile(r"(://[^/@\s:]+):([^/@\s]+)@")
46
+ _SENSITIVE_NAME = r"[A-Z0-9_]*(?:TOKEN|PASSWORD|SECRET|KEY|AUTHORIZATION)"
47
+ _ASSIGNMENT = re.compile(rf"(?i)(\b{_SENSITIVE_NAME}\b\s*[:=]\s*)([^\s,;]+)")
48
+ _UNIX_HOME = re.compile(r"(?i)(/(?:home|Users)/)[^/\s]+")
49
+ _WINDOWS_HOME = re.compile(r"(?i)([A-Z]:\\Users\\)[^\\\s]+")
50
+ _TOKEN_PREFIX = re.compile(r"(?i)\b(?:ghp|gho|ghu|glpat|github_pat|hf_)[A-Za-z0-9_\-]{6,}")
51
+
52
+
53
+ def _redact_message(message: str) -> str:
54
+ redacted = _URL_CREDENTIALS.sub(r"\1:***@", message)
55
+ redacted = _ASSIGNMENT.sub(r"\1[REDACTED]", redacted)
56
+ redacted = _UNIX_HOME.sub(r"\1[USER]", redacted)
57
+ redacted = _WINDOWS_HOME.sub(r"\1[USER]", redacted)
58
+ return _TOKEN_PREFIX.sub("[REDACTED]", redacted)
59
+
60
+
61
+ class _JsonFormatter(logging.Formatter):
62
+ """输出单行 JSON:{ts, level, logger, message} + 自定义 extra 字段。"""
63
+
64
+ def format(self, record: logging.LogRecord) -> str:
65
+ payload: dict[str, Any] = {
66
+ "ts": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
67
+ "level": record.levelname,
68
+ "logger": record.name,
69
+ "message": _redact_message(record.getMessage()),
70
+ }
71
+ for key, value in record.__dict__.items():
72
+ if key not in _SKIP_ATTRS and not key.startswith("_"):
73
+ payload[key] = value
74
+ return json.dumps(payload, ensure_ascii=False, default=str)
75
+
76
+
77
+ def setup_logging(log_level: str = "INFO") -> None:
78
+ """为 `mxready` logger 配置 JSON 结构化输出(幂等,可重复调用)。"""
79
+ logger = logging.getLogger(_LOGGER_NAME)
80
+ if logger.handlers:
81
+ return
82
+
83
+ level = getattr(logging, str(log_level).upper(), logging.INFO)
84
+ logger.setLevel(level)
85
+ handler = logging.StreamHandler(sys.stdout)
86
+ handler.setFormatter(_JsonFormatter())
87
+ logger.addHandler(handler)
88
+ logger.propagate = False
@@ -0,0 +1,5 @@
1
+ """Optional Model Context Protocol front end for MXReady.
2
+
3
+ Importing this package does not require the ``mcp`` SDK; only
4
+ :mod:`mxready.mcp.server` does.
5
+ """