whycode-cli 0.2.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.
whycode/git_facts.py ADDED
@@ -0,0 +1,450 @@
1
+ """Layer 1: deterministic git facts.
2
+
3
+ Pure git plumbing wrapped in safe Python. Never interprets, never guesses.
4
+ The output is the bedrock that Layer 2 builds on.
5
+
6
+ Design notes
7
+ ------------
8
+ - We delimit log output with ASCII unit (0x1f) and record (0x1e) separators
9
+ because they essentially never appear in commit messages or paths.
10
+ - We use ``--follow`` so file rename history is traced through.
11
+ - We never invoke a subcommand that mutates the repo.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ import subprocess
18
+ from collections import Counter
19
+ from collections.abc import Sequence
20
+ from dataclasses import dataclass, field
21
+ from datetime import datetime
22
+ from pathlib import Path
23
+
24
+ UNIT_SEP = "\x1f"
25
+ RECORD_SEP = "\x1e"
26
+
27
+ # A commit subject/body containing one of these markers is treated as evidence
28
+ # that the original author flagged something worth carrying forward.
29
+ INCIDENT_TOKENS: tuple[str, ...] = (
30
+ "hotfix",
31
+ "incident",
32
+ "outage",
33
+ "p0",
34
+ "p1",
35
+ "sev1",
36
+ "sev2",
37
+ "production down",
38
+ "rollback",
39
+ "regression",
40
+ )
41
+ _INCIDENT_RE = re.compile(
42
+ r"|".join(rf"\b{re.escape(tok)}\b" for tok in INCIDENT_TOKENS),
43
+ re.IGNORECASE,
44
+ )
45
+ # A Conventional Commits structured marker. Unlike free-form keywords above,
46
+ # this is a deliberate, anchored footer — high enough confidence to fire on
47
+ # body alone with no need for a corroborating issue ID.
48
+ _BREAKING_FOOTER_RE = re.compile(r"\bBREAKING[- ]CHANGE:", re.IGNORECASE)
49
+ # Conventional Commits "breaking" indicator: ``feat!:``, ``fix!:``, ``refactor!:``…
50
+ # Anchored to the start of the subject line (or after whitespace) and limited
51
+ # to known type tokens so we don't match URL fragments like ``foo!:bar``.
52
+ _BREAKING_CC_RE = re.compile(
53
+ r"(?:^|\s)(?:feat|fix|chore|refactor|perf|build|ci|docs|test|style|revert)!:",
54
+ re.IGNORECASE,
55
+ )
56
+
57
+ # Issue / incident identifiers that corroborate a body-only incident keyword:
58
+ # - GitHub-style: #1234
59
+ # - Jira-style: ABC-123
60
+ # - Severity: SEV-1, sev1, P0, P1
61
+ # Used to raise body matches above the "passing mention in prose" floor.
62
+ _ISSUE_ID_RE = re.compile(
63
+ r"(?:#\d+|\b[A-Z][A-Z0-9_]+-\d+|\bSEV[- ]?\d\b|\bP[01]\b)",
64
+ )
65
+ INVARIANT_TOKENS: tuple[str, ...] = (
66
+ "do not",
67
+ "don't",
68
+ "must not",
69
+ "warning:",
70
+ "important:",
71
+ "danger:",
72
+ "note:",
73
+ "invariant",
74
+ "workaround",
75
+ "tradeoff",
76
+ )
77
+ # Compiled once: each token must appear as a whole phrase. Tokens that already
78
+ # end in a colon or apostrophe are treated literally; otherwise we require word
79
+ # boundaries so e.g. "guard" does not match "scope guard" of "guard rail".
80
+ _INVARIANT_RE = re.compile(
81
+ r"|".join(
82
+ rf"\b{re.escape(tok)}\b" if re.match(r"^[a-z][a-z ]*$", tok) else re.escape(tok)
83
+ for tok in INVARIANT_TOKENS
84
+ ),
85
+ re.IGNORECASE,
86
+ )
87
+
88
+
89
+ @dataclass(frozen=True)
90
+ class Commit:
91
+ sha: str
92
+ author_name: str
93
+ author_email: str
94
+ authored_at: datetime
95
+ subject: str
96
+ body: str
97
+ files: tuple[str, ...] = ()
98
+
99
+ @property
100
+ def message(self) -> str:
101
+ return f"{self.subject}\n\n{self.body}".strip()
102
+
103
+
104
+ @dataclass(frozen=True)
105
+ class FileChange:
106
+ sha: str
107
+ path: str
108
+ insertions: int
109
+ deletions: int
110
+
111
+
112
+ @dataclass
113
+ class RepoFacts:
114
+ """Snapshot of facts relevant to a single file."""
115
+
116
+ repo_root: Path
117
+ path: str
118
+ commits: list[Commit] = field(default_factory=list)
119
+ co_changed_files: Counter[str] = field(default_factory=Counter)
120
+ revert_pairs: list[tuple[str, str]] = field(default_factory=list)
121
+ """Pairs of (revert_commit_sha, reverted_commit_sha)."""
122
+
123
+ incident_commits: list[Commit] = field(default_factory=list)
124
+ invariant_quotes: list[tuple[str, str]] = field(default_factory=list)
125
+ """Pairs of (commit_sha, line containing an invariant token)."""
126
+
127
+
128
+ class GitError(RuntimeError):
129
+ """Raised when a git invocation fails or produces unexpected output."""
130
+
131
+
132
+ def _run_git(repo_root: Path, *args: str) -> str:
133
+ """Invoke git, return stdout. Raises GitError on non-zero exit."""
134
+ cmd = ["git", "-C", str(repo_root), *args]
135
+ try:
136
+ proc = subprocess.run(
137
+ cmd,
138
+ check=False,
139
+ capture_output=True,
140
+ text=True,
141
+ encoding="utf-8",
142
+ errors="replace",
143
+ )
144
+ except FileNotFoundError as exc:
145
+ raise GitError("git executable not found on PATH") from exc
146
+ if proc.returncode != 0:
147
+ raise GitError(
148
+ f"git {' '.join(args)} failed (exit {proc.returncode}): {proc.stderr.strip()}"
149
+ )
150
+ return proc.stdout
151
+
152
+
153
+ def discover_repo_root(start: Path) -> Path:
154
+ """Find the enclosing git repo root for ``start``."""
155
+ out = _run_git(start, "rev-parse", "--show-toplevel").strip()
156
+ if not out:
157
+ raise GitError(f"{start} is not inside a git repository")
158
+ return Path(out)
159
+
160
+
161
+ def is_tracked(repo_root: Path, path: str) -> bool:
162
+ """Return True if ``path`` is tracked by git in ``repo_root``."""
163
+ try:
164
+ out = _run_git(repo_root, "ls-files", "--error-unmatch", "--", path)
165
+ except GitError:
166
+ return False
167
+ return bool(out.strip())
168
+
169
+
170
+ def _parse_iso(timestamp: str) -> datetime:
171
+ return datetime.fromisoformat(timestamp.strip())
172
+
173
+
174
+ def _log_format() -> str:
175
+ """The format used to serialise a commit on a single record."""
176
+ fields = ["%H", "%an", "%ae", "%aI", "%s", "%b"]
177
+ return UNIT_SEP.join(fields) + RECORD_SEP
178
+
179
+
180
+ def _parse_log_records(raw: str) -> list[Commit]:
181
+ commits: list[Commit] = []
182
+ for record in raw.split(RECORD_SEP):
183
+ record = record.strip("\n")
184
+ if not record:
185
+ continue
186
+ parts = record.split(UNIT_SEP)
187
+ if len(parts) < 6:
188
+ # Body may contain a UNIT_SEP only if a contributor pasted one in
189
+ # — vanishingly rare, but be defensive: re-stitch the trailing fields.
190
+ head = parts[:5]
191
+ body = UNIT_SEP.join(parts[5:])
192
+ parts = [*head, body]
193
+ sha, author_name, author_email, authored_at, subject, body = parts
194
+ commits.append(
195
+ Commit(
196
+ sha=sha.strip(),
197
+ author_name=author_name,
198
+ author_email=author_email,
199
+ authored_at=_parse_iso(authored_at),
200
+ subject=subject,
201
+ body=body.strip("\n"),
202
+ )
203
+ )
204
+ return commits
205
+
206
+
207
+ def commits_for_path(
208
+ repo_root: Path,
209
+ path: str,
210
+ *,
211
+ max_count: int | None = None,
212
+ ref: str | None = None,
213
+ ) -> list[Commit]:
214
+ """Return commits that touched ``path`` (rename-aware), newest first.
215
+
216
+ When ``ref`` is given, only commits reachable from that revision are
217
+ returned — i.e., the file's history *as of* that point in time.
218
+ """
219
+ args = [
220
+ "log",
221
+ "--follow",
222
+ "--no-merges",
223
+ f"--pretty=format:{_log_format()}",
224
+ ]
225
+ if max_count is not None:
226
+ args.append(f"--max-count={max_count}")
227
+ if ref is not None:
228
+ args.append(ref)
229
+ args.extend(["--", path])
230
+ raw = _run_git(repo_root, *args)
231
+ return _parse_log_records(raw)
232
+
233
+
234
+ def all_commits(repo_root: Path, *, max_count: int | None = None) -> list[Commit]:
235
+ """Return all commits in repo, newest first. Used for revert / ghost-author scans."""
236
+ args = ["log", "--no-merges", f"--pretty=format:{_log_format()}"]
237
+ if max_count is not None:
238
+ args.append(f"--max-count={max_count}")
239
+ raw = _run_git(repo_root, *args)
240
+ return _parse_log_records(raw)
241
+
242
+
243
+ def files_changed_in(repo_root: Path, sha: str) -> list[FileChange]:
244
+ """Return the list of files (with diffstat) changed in ``sha``."""
245
+ raw = _run_git(
246
+ repo_root, "show", "--no-renames", "--numstat", "--format=", sha
247
+ )
248
+ out: list[FileChange] = []
249
+ for line in raw.splitlines():
250
+ line = line.strip()
251
+ if not line:
252
+ continue
253
+ parts = line.split("\t")
254
+ if len(parts) != 3:
255
+ continue
256
+ ins_s, del_s, path = parts
257
+ # Binary files appear as "-" "-".
258
+ try:
259
+ insertions = int(ins_s) if ins_s != "-" else 0
260
+ deletions = int(del_s) if del_s != "-" else 0
261
+ except ValueError:
262
+ continue
263
+ out.append(FileChange(sha=sha, path=path, insertions=insertions, deletions=deletions))
264
+ return out
265
+
266
+
267
+ def co_changes(
268
+ repo_root: Path,
269
+ commits: Sequence[Commit],
270
+ target_path: str,
271
+ ) -> Counter[str]:
272
+ """Count, across the given commits, how often other files changed alongside ``target_path``.
273
+
274
+ The target file is excluded from the result.
275
+ """
276
+ counter: Counter[str] = Counter()
277
+ for commit in commits:
278
+ for change in files_changed_in(repo_root, commit.sha):
279
+ if change.path == target_path:
280
+ continue
281
+ counter[change.path] += 1
282
+ return counter
283
+
284
+
285
+ _REVERT_PREFIX = 'this reverts commit '
286
+
287
+
288
+ def find_revert_pairs(commits: Sequence[Commit]) -> list[tuple[str, str]]:
289
+ """Detect (revert_sha, reverted_sha) pairs from commit messages.
290
+
291
+ Git's default revert message body contains ``This reverts commit <sha>.``.
292
+ We are tolerant of leading whitespace and trailing punctuation.
293
+ """
294
+ pairs: list[tuple[str, str]] = []
295
+ for commit in commits:
296
+ for line in commit.message.splitlines():
297
+ stripped = line.strip().lower()
298
+ if not stripped.startswith(_REVERT_PREFIX):
299
+ continue
300
+ after = stripped[len(_REVERT_PREFIX) :].strip().rstrip(".")
301
+ # The first whitespace-separated token is the SHA.
302
+ token = after.split()[0] if after else ""
303
+ if len(token) >= 7 and all(c in "0123456789abcdef" for c in token):
304
+ pairs.append((commit.sha, token))
305
+ break
306
+ return pairs
307
+
308
+
309
+ def find_incidents(commits: Sequence[Commit]) -> list[Commit]:
310
+ """Return commits whose evidence-level signals incident-flavored intent.
311
+
312
+ Acceptance ladder (highest to lowest confidence):
313
+ 1. Subject contains an incident keyword. A commit's subject is its
314
+ declared purpose, so a subject hit is treated as ground truth.
315
+ 2. Subject carries the Conventional Commits breaking marker
316
+ (``feat!:`` / ``fix!:`` / …).
317
+ 3. Body carries the structured ``BREAKING CHANGE:`` footer. This is a
318
+ deliberate, anchored marker, not free-form prose.
319
+ 4. Body contains an incident keyword AND an issue / incident
320
+ identifier nearby (``#1234``, ``INC-447``, ``SEV-1``, ``P0``).
321
+ This filters out passing mentions in prose like "feat: add
322
+ incident-aware logging" where the keyword describes a *feature*.
323
+
324
+ A bare body keyword with no corroborating ID does NOT fire.
325
+ """
326
+ out: list[Commit] = []
327
+ for c in commits:
328
+ if _INCIDENT_RE.search(c.subject) or _BREAKING_CC_RE.search(c.subject):
329
+ out.append(c)
330
+ continue
331
+ if _BREAKING_FOOTER_RE.search(c.body):
332
+ out.append(c)
333
+ continue
334
+ if _INCIDENT_RE.search(c.body) and _ISSUE_ID_RE.search(c.body):
335
+ out.append(c)
336
+ return out
337
+
338
+
339
+ # Straight, backtick, and the four common Unicode "smart" quote code points.
340
+ # We build the string from chr() calls because ruff's RUF001 ambiguous-char
341
+ # check rejects the literal Unicode quotes inline.
342
+ _QUOTE_CHARS = "\"'`" + "".join(chr(c) for c in (0x2018, 0x2019, 0x201C, 0x201D))
343
+
344
+
345
+ def _all_matches_are_quoted(line: str, regex: re.Pattern[str]) -> bool:
346
+ """True iff every match of ``regex`` in ``line`` is immediately bracketed
347
+ by quote characters — i.e. the tokens are being *named* rather than used.
348
+ """
349
+ matches = list(regex.finditer(line))
350
+ if not matches:
351
+ return False
352
+ for m in matches:
353
+ before = line[m.start() - 1] if m.start() > 0 else ""
354
+ after = line[m.end()] if m.end() < len(line) else ""
355
+ if before in _QUOTE_CHARS and after in _QUOTE_CHARS:
356
+ continue
357
+ return False
358
+ return True
359
+
360
+
361
+ def extract_invariant_quotes(commits: Sequence[Commit]) -> list[tuple[str, str]]:
362
+ """Pull lines from commit *bodies* that match invariant tokens.
363
+
364
+ Returns pairs of (sha, the matching line) — verbatim, capped at 200 chars.
365
+
366
+ Body-only because the subject describes what the commit did; an actual
367
+ constraint is almost always stated in the body. Skipping the subject also
368
+ eliminates the meta-mention failure mode where a commit *about* an
369
+ invariant token (e.g. "fix invariant matcher") would self-flag.
370
+
371
+ Lines where every matching token is wrapped in quotes (``"do not"``) are
372
+ treated as references rather than statements and are skipped.
373
+ """
374
+ out: list[tuple[str, str]] = []
375
+ for commit in commits:
376
+ for raw_line in commit.body.splitlines():
377
+ line = raw_line.strip()
378
+ if not line:
379
+ continue
380
+ if not _INVARIANT_RE.search(line):
381
+ continue
382
+ if _all_matches_are_quoted(line, _INVARIANT_RE):
383
+ continue
384
+ out.append((commit.sha, line[:200]))
385
+ return out
386
+
387
+
388
+ def author_last_activity(repo_root: Path, email: str) -> datetime | None:
389
+ """Most recent commit timestamp by ``email`` anywhere in the repo, or None."""
390
+ raw = _run_git(
391
+ repo_root,
392
+ "log",
393
+ "-1",
394
+ "--all",
395
+ f"--author={email}",
396
+ "--pretty=format:%aI",
397
+ )
398
+ raw = raw.strip()
399
+ if not raw:
400
+ return None
401
+ try:
402
+ return _parse_iso(raw)
403
+ except ValueError:
404
+ return None
405
+
406
+
407
+ def line_ownership(repo_root: Path, path: str) -> dict[str, int]:
408
+ """Return ``{author_email: line_count}`` from ``git blame`` of HEAD's ``path``.
409
+
410
+ Empty dict if blame is unavailable (file deleted, binary, etc.). Used by
411
+ Layer 2 to refine ghost-keeper detection: line ownership is a stronger
412
+ signal than commit count, which can be skewed by a single big initial
413
+ commit followed by many tiny fixes.
414
+ """
415
+ try:
416
+ raw = _run_git(repo_root, "blame", "--line-porcelain", "HEAD", "--", path)
417
+ except GitError:
418
+ return {}
419
+ counts: dict[str, int] = {}
420
+ current_email: str | None = None
421
+ for line in raw.splitlines():
422
+ if line.startswith("author-mail "):
423
+ current_email = line[len("author-mail "):].strip().strip("<>")
424
+ elif line.startswith("\t") and current_email:
425
+ counts[current_email] = counts.get(current_email, 0) + 1
426
+ return counts
427
+
428
+
429
+ def gather(
430
+ repo_root: Path,
431
+ path: str,
432
+ *,
433
+ max_commits: int | None = None,
434
+ ref: str | None = None,
435
+ ) -> RepoFacts:
436
+ """Top-level convenience: build a RepoFacts snapshot for ``path``.
437
+
438
+ Pass ``ref`` to compute facts as of a past commit (e.g., for postmortem
439
+ "what did this file's risk look like at the time of the outage" queries).
440
+ """
441
+ commits = commits_for_path(repo_root, path, max_count=max_commits, ref=ref)
442
+ return RepoFacts(
443
+ repo_root=repo_root,
444
+ path=path,
445
+ commits=commits,
446
+ co_changed_files=co_changes(repo_root, commits, path),
447
+ revert_pairs=find_revert_pairs(commits),
448
+ incident_commits=find_incidents(commits),
449
+ invariant_quotes=extract_invariant_quotes(commits),
450
+ )
whycode/mcp_server.py ADDED
@@ -0,0 +1,204 @@
1
+ """MCP server for WhyCode.
2
+
3
+ Exposes WhyCode's Risk Card to MCP-aware editors and assistants so the host
4
+ LLM can pull a file's risk profile *before* it edits the code.
5
+
6
+ Tools
7
+ -----
8
+ - ``get_risk_profile(path)`` — full Risk Card.
9
+ - ``get_file_decisions(path, limit=5)`` — decision-flavoured signals only
10
+ (incidents, reverts, invariants), highest severity first.
11
+
12
+ The server speaks stdio. Configure your client with:
13
+
14
+ {
15
+ "mcpServers": {
16
+ "whycode": {"command": "whycode", "args": ["mcp"]}
17
+ }
18
+ }
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+ import json
25
+ import sys
26
+ import time
27
+ from pathlib import Path
28
+ from typing import Any
29
+
30
+ from mcp.server import Server
31
+ from mcp.server.stdio import stdio_server
32
+ from mcp.types import TextContent, Tool
33
+
34
+ from whycode import git_facts as gf
35
+ from whycode import risk_card as rc
36
+ from whycode.signals import SignalKind
37
+
38
+ DECISION_KINDS = {
39
+ SignalKind.REVERT_CHAIN,
40
+ SignalKind.INCIDENT_HISTORY,
41
+ SignalKind.INVARIANT_QUOTE,
42
+ SignalKind.GHOST_KEEPER,
43
+ }
44
+
45
+
46
+ def _resolve(path: str) -> tuple[Path, str]:
47
+ p = Path(path).resolve()
48
+ start = p if p.is_dir() else p.parent if p.exists() else Path.cwd()
49
+ repo_root = gf.discover_repo_root(start)
50
+ if p.exists():
51
+ try:
52
+ return repo_root, str(p.relative_to(repo_root))
53
+ except ValueError as exc:
54
+ raise gf.GitError(f"{p} is not inside {repo_root}") from exc
55
+ return repo_root, path
56
+
57
+
58
+ def _log_call(name: str, arguments: dict[str, Any]) -> None:
59
+ """Print a one-line audit record to stderr (for `whycode mcp --verbose`)."""
60
+ stamp = time.strftime("%H:%M:%S")
61
+ path = arguments.get("path", "?")
62
+ print(f"[whycode {stamp}] {name}(path={path!r})", file=sys.stderr, flush=True)
63
+
64
+
65
+ def _build_server(verbose: bool = False) -> Server:
66
+ server: Server = Server("whycode")
67
+
68
+ @server.list_tools() # type: ignore[no-untyped-call,untyped-decorator]
69
+ async def _list_tools() -> list[Tool]:
70
+ return [
71
+ Tool(
72
+ name="get_risk_profile",
73
+ description=(
74
+ "Return the WhyCode Risk Card for the given file path: a 0..100 "
75
+ "score, a band label, and the list of fired signals (revert "
76
+ "chains, incidents, coupling, silence, ghost keeper, invariant "
77
+ "quotes). Call this BEFORE editing any file you are unfamiliar "
78
+ "with — the response includes the SHAs that justify each flag."
79
+ ),
80
+ inputSchema={
81
+ "type": "object",
82
+ "properties": {
83
+ "path": {
84
+ "type": "string",
85
+ "description": "Path to the file (absolute or repo-relative).",
86
+ },
87
+ "max_commits": {
88
+ "type": "integer",
89
+ "description": "Optional cap on commits scanned.",
90
+ },
91
+ },
92
+ "required": ["path"],
93
+ },
94
+ ),
95
+ Tool(
96
+ name="get_file_decisions",
97
+ description=(
98
+ "Return decision-flavoured signals only — past reverts, "
99
+ "incident-tagged changes, ghost keepers, and invariants stated "
100
+ "verbatim by past authors. Use when you specifically want the "
101
+ "'why' of past changes, not the broader risk picture."
102
+ ),
103
+ inputSchema={
104
+ "type": "object",
105
+ "properties": {
106
+ "path": {"type": "string"},
107
+ "limit": {"type": "integer", "default": 5},
108
+ },
109
+ "required": ["path"],
110
+ },
111
+ ),
112
+ ]
113
+
114
+ @server.call_tool() # type: ignore[untyped-decorator]
115
+ async def _call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
116
+ if verbose:
117
+ _log_call(name, arguments)
118
+ if name == "get_risk_profile":
119
+ return _handle_risk_profile(arguments)
120
+ if name == "get_file_decisions":
121
+ return _handle_file_decisions(arguments)
122
+ raise ValueError(f"Unknown tool: {name}")
123
+
124
+ return server
125
+
126
+
127
+ def _summary_text(card: rc.RiskCard) -> str:
128
+ """One-paragraph prose summary of the card. Designed to be quotable verbatim
129
+ by an LLM consumer without further processing."""
130
+ if not card.signals:
131
+ return (
132
+ f"{card.path}: {card.score.band.value} ({card.score.value}/100). "
133
+ f"No flagged signals across {card.commit_count} commits — but read "
134
+ f"the diff anyway."
135
+ )
136
+ top = card.signals[0]
137
+ extras = ""
138
+ if len(card.signals) > 1:
139
+ extras = f" Plus {len(card.signals) - 1} more signal(s) in the full card."
140
+ return (
141
+ f"{card.path}: {card.score.band.value} ({card.score.value}/100). "
142
+ f"Top concern: {top.headline}.{extras}"
143
+ )
144
+
145
+
146
+ def _handle_risk_profile(arguments: dict[str, Any]) -> list[TextContent]:
147
+ path = str(arguments["path"])
148
+ max_commits = arguments.get("max_commits")
149
+ try:
150
+ repo_root, rel = _resolve(path)
151
+ card = rc.build(repo_root, rel, max_commits=max_commits)
152
+ except gf.GitError as exc:
153
+ return [TextContent(type="text", text=json.dumps({"error": str(exc)}))]
154
+ payload = card.to_dict()
155
+ payload["summary"] = _summary_text(card)
156
+ return [TextContent(type="text", text=json.dumps(payload, indent=2))]
157
+
158
+
159
+ def _handle_file_decisions(arguments: dict[str, Any]) -> list[TextContent]:
160
+ path = str(arguments["path"])
161
+ limit = int(arguments.get("limit", 5))
162
+ try:
163
+ repo_root, rel = _resolve(path)
164
+ card = rc.build(repo_root, rel)
165
+ except gf.GitError as exc:
166
+ return [TextContent(type="text", text=json.dumps({"error": str(exc)}))]
167
+ decisions = [s for s in card.signals if s.kind in DECISION_KINDS][:limit]
168
+ payload = {
169
+ "path": card.path,
170
+ "score": card.score.value,
171
+ "band": card.score.band.value,
172
+ "summary": _summary_text(card),
173
+ "decisions": [
174
+ {
175
+ "kind": s.kind.value,
176
+ "severity": s.severity,
177
+ "headline": s.headline,
178
+ "detail": s.detail,
179
+ "evidence": list(s.evidence),
180
+ }
181
+ for s in decisions
182
+ ],
183
+ }
184
+ return [TextContent(type="text", text=json.dumps(payload, indent=2))]
185
+
186
+
187
+ async def _run(verbose: bool) -> None:
188
+ server = _build_server(verbose=verbose)
189
+ if verbose:
190
+ print(
191
+ "[whycode] MCP server up. Tool calls from the AI will be logged below.",
192
+ file=sys.stderr,
193
+ flush=True,
194
+ )
195
+ async with stdio_server() as (reader, writer):
196
+ await server.run(reader, writer, server.create_initialization_options())
197
+
198
+
199
+ def serve(verbose: bool = False) -> None:
200
+ """Block on the MCP server. Used by ``whycode mcp``."""
201
+ asyncio.run(_run(verbose))
202
+
203
+
204
+ __all__ = ["serve"]