whycode-cli 0.3.0__tar.gz → 0.3.1__tar.gz

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 (35) hide show
  1. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/PKG-INFO +1 -1
  2. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/pyproject.toml +1 -1
  3. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode/__init__.py +1 -1
  4. whycode_cli-0.3.1/src/whycode/mcp_server.py +509 -0
  5. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode_cli.egg-info/PKG-INFO +1 -1
  6. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode_cli.egg-info/SOURCES.txt +1 -0
  7. whycode_cli-0.3.1/tests/test_mcp_prompts.py +315 -0
  8. whycode_cli-0.3.0/src/whycode/mcp_server.py +0 -204
  9. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/LICENSE +0 -0
  10. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/README.md +0 -0
  11. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/setup.cfg +0 -0
  12. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode/__main__.py +0 -0
  13. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode/cli.py +0 -0
  14. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode/decisions.py +0 -0
  15. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode/git_facts.py +0 -0
  16. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode/ignore.py +0 -0
  17. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode/llm.py +0 -0
  18. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode/risk_card.py +0 -0
  19. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode/scorer.py +0 -0
  20. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode/signals.py +0 -0
  21. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode/suppressions.py +0 -0
  22. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode/templates/__init__.py +0 -0
  23. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode/templates/github-workflow.yml +0 -0
  24. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode/templates/pre-commit +0 -0
  25. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode_cli.egg-info/dependency_links.txt +0 -0
  26. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode_cli.egg-info/entry_points.txt +0 -0
  27. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode_cli.egg-info/requires.txt +0 -0
  28. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/src/whycode_cli.egg-info/top_level.txt +0 -0
  29. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/tests/test_cli.py +0 -0
  30. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/tests/test_decisions.py +0 -0
  31. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/tests/test_git_facts.py +0 -0
  32. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/tests/test_ignore.py +0 -0
  33. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/tests/test_scorer.py +0 -0
  34. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/tests/test_signals.py +0 -0
  35. {whycode_cli-0.3.0 → whycode_cli-0.3.1}/tests/test_suppressions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whycode-cli
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Tells you what to be afraid of before you touch a file.
5
5
  Author: Kevin
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "whycode-cli"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Tells you what to be afraid of before you touch a file."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,3 +1,3 @@
1
1
  """WhyCode — tells you what to be afraid of before touching a file."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.3.1"
@@ -0,0 +1,509 @@
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
+ Prompts
13
+ -------
14
+ Reusable prompt templates the host can offer the user as one-click actions.
15
+ The server fills in WhyCode data; the host LLM does the actual reasoning.
16
+ No outbound network calls happen here -- prompts are pure local data plus a
17
+ short instruction wrapper, exactly like tools.
18
+
19
+ - ``before_edit_checklist(path)`` -- fetch the Risk Card and ask the model to
20
+ walk the user through every HIGH-severity signal before suggesting an edit.
21
+ - ``summarise_for_postmortem(sha)`` -- fetch a commit's metadata and
22
+ classification and ask the model to draft a postmortem-ready summary.
23
+ - ``risk_briefing_for_pr(base)`` -- fetch the diff risk briefing and ask the
24
+ model to summarise it for a reviewer in 3-5 bullets.
25
+
26
+ The server speaks stdio. Configure your client with:
27
+
28
+ {
29
+ "mcpServers": {
30
+ "whycode": {"command": "whycode", "args": ["mcp"]}
31
+ }
32
+ }
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import asyncio
38
+ import json
39
+ import sys
40
+ import time
41
+ from pathlib import Path
42
+ from typing import Any
43
+
44
+ from mcp.server import Server
45
+ from mcp.server.stdio import stdio_server
46
+ from mcp.types import (
47
+ GetPromptResult,
48
+ Prompt,
49
+ PromptArgument,
50
+ PromptMessage,
51
+ TextContent,
52
+ Tool,
53
+ )
54
+
55
+ from whycode import git_facts as gf
56
+ from whycode import risk_card as rc
57
+ from whycode.signals import SignalKind
58
+
59
+ DECISION_KINDS = {
60
+ SignalKind.REVERT_CHAIN,
61
+ SignalKind.INCIDENT_HISTORY,
62
+ SignalKind.INVARIANT_QUOTE,
63
+ SignalKind.GHOST_KEEPER,
64
+ }
65
+
66
+
67
+ def _resolve(path: str) -> tuple[Path, str]:
68
+ p = Path(path).resolve()
69
+ start = p if p.is_dir() else p.parent if p.exists() else Path.cwd()
70
+ repo_root = gf.discover_repo_root(start)
71
+ if p.exists():
72
+ try:
73
+ return repo_root, str(p.relative_to(repo_root))
74
+ except ValueError as exc:
75
+ raise gf.GitError(f"{p} is not inside {repo_root}") from exc
76
+ return repo_root, path
77
+
78
+
79
+ def _log_call(name: str, arguments: dict[str, Any]) -> None:
80
+ """Print a one-line audit record to stderr (for `whycode mcp --verbose`)."""
81
+ stamp = time.strftime("%H:%M:%S")
82
+ path = arguments.get("path", "?")
83
+ print(f"[whycode {stamp}] {name}(path={path!r})", file=sys.stderr, flush=True)
84
+
85
+
86
+ def _build_server(verbose: bool = False) -> Server:
87
+ server: Server = Server("whycode")
88
+
89
+ @server.list_tools() # type: ignore[no-untyped-call,untyped-decorator]
90
+ async def _list_tools() -> list[Tool]:
91
+ return [
92
+ Tool(
93
+ name="get_risk_profile",
94
+ description=(
95
+ "Return the WhyCode Risk Card for the given file path: a 0..100 "
96
+ "score, a band label, and the list of fired signals (revert "
97
+ "chains, incidents, coupling, silence, ghost keeper, invariant "
98
+ "quotes). Call this BEFORE editing any file you are unfamiliar "
99
+ "with — the response includes the SHAs that justify each flag."
100
+ ),
101
+ inputSchema={
102
+ "type": "object",
103
+ "properties": {
104
+ "path": {
105
+ "type": "string",
106
+ "description": "Path to the file (absolute or repo-relative).",
107
+ },
108
+ "max_commits": {
109
+ "type": "integer",
110
+ "description": "Optional cap on commits scanned.",
111
+ },
112
+ },
113
+ "required": ["path"],
114
+ },
115
+ ),
116
+ Tool(
117
+ name="get_file_decisions",
118
+ description=(
119
+ "Return decision-flavoured signals only — past reverts, "
120
+ "incident-tagged changes, ghost keepers, and invariants stated "
121
+ "verbatim by past authors. Use when you specifically want the "
122
+ "'why' of past changes, not the broader risk picture."
123
+ ),
124
+ inputSchema={
125
+ "type": "object",
126
+ "properties": {
127
+ "path": {"type": "string"},
128
+ "limit": {"type": "integer", "default": 5},
129
+ },
130
+ "required": ["path"],
131
+ },
132
+ ),
133
+ ]
134
+
135
+ @server.call_tool() # type: ignore[untyped-decorator]
136
+ async def _call_tool(name: str, arguments: dict[str, Any]) -> list[TextContent]:
137
+ if verbose:
138
+ _log_call(name, arguments)
139
+ if name == "get_risk_profile":
140
+ return _handle_risk_profile(arguments)
141
+ if name == "get_file_decisions":
142
+ return _handle_file_decisions(arguments)
143
+ raise ValueError(f"Unknown tool: {name}")
144
+
145
+ @server.list_prompts() # type: ignore[no-untyped-call,untyped-decorator]
146
+ async def _list_prompts() -> list[Prompt]:
147
+ return list(_PROMPTS)
148
+
149
+ @server.get_prompt() # type: ignore[no-untyped-call,untyped-decorator]
150
+ async def _get_prompt(
151
+ name: str, arguments: dict[str, str] | None
152
+ ) -> GetPromptResult:
153
+ if verbose:
154
+ _log_call(f"prompt:{name}", dict(arguments or {}))
155
+ return _render_prompt(name, arguments or {})
156
+
157
+ return server
158
+
159
+
160
+ def _summary_text(card: rc.RiskCard) -> str:
161
+ """One-paragraph prose summary of the card. Designed to be quotable verbatim
162
+ by an LLM consumer without further processing."""
163
+ if not card.signals:
164
+ return (
165
+ f"{card.path}: {card.score.band.value} ({card.score.value}/100). "
166
+ f"No flagged signals across {card.commit_count} commits — but read "
167
+ f"the diff anyway."
168
+ )
169
+ top = card.signals[0]
170
+ extras = ""
171
+ if len(card.signals) > 1:
172
+ extras = f" Plus {len(card.signals) - 1} more signal(s) in the full card."
173
+ return (
174
+ f"{card.path}: {card.score.band.value} ({card.score.value}/100). "
175
+ f"Top concern: {top.headline}.{extras}"
176
+ )
177
+
178
+
179
+ def _handle_risk_profile(arguments: dict[str, Any]) -> list[TextContent]:
180
+ path = str(arguments["path"])
181
+ max_commits = arguments.get("max_commits")
182
+ try:
183
+ repo_root, rel = _resolve(path)
184
+ card = rc.build(repo_root, rel, max_commits=max_commits)
185
+ except gf.GitError as exc:
186
+ return [TextContent(type="text", text=json.dumps({"error": str(exc)}))]
187
+ payload = card.to_dict()
188
+ payload["summary"] = _summary_text(card)
189
+ return [TextContent(type="text", text=json.dumps(payload, indent=2))]
190
+
191
+
192
+ def _handle_file_decisions(arguments: dict[str, Any]) -> list[TextContent]:
193
+ path = str(arguments["path"])
194
+ limit = int(arguments.get("limit", 5))
195
+ try:
196
+ repo_root, rel = _resolve(path)
197
+ card = rc.build(repo_root, rel)
198
+ except gf.GitError as exc:
199
+ return [TextContent(type="text", text=json.dumps({"error": str(exc)}))]
200
+ decisions = [s for s in card.signals if s.kind in DECISION_KINDS][:limit]
201
+ payload = {
202
+ "path": card.path,
203
+ "score": card.score.value,
204
+ "band": card.score.band.value,
205
+ "summary": _summary_text(card),
206
+ "decisions": [
207
+ {
208
+ "kind": s.kind.value,
209
+ "severity": s.severity,
210
+ "headline": s.headline,
211
+ "detail": s.detail,
212
+ "evidence": list(s.evidence),
213
+ }
214
+ for s in decisions
215
+ ],
216
+ }
217
+ return [TextContent(type="text", text=json.dumps(payload, indent=2))]
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Prompts
222
+ # ---------------------------------------------------------------------------
223
+ #
224
+ # Prompts are saved-search shortcuts: the host editor surfaces them as
225
+ # one-click actions; the server fills in WhyCode data; the host LLM does
226
+ # the reasoning. They never make outbound network calls -- the data is
227
+ # strictly local git history, exactly like the tool surface.
228
+
229
+ _BEFORE_EDIT = "before_edit_checklist"
230
+ _POSTMORTEM = "summarise_for_postmortem"
231
+ _PR_BRIEFING = "risk_briefing_for_pr"
232
+
233
+ _PROMPTS: tuple[Prompt, ...] = (
234
+ Prompt(
235
+ name=_BEFORE_EDIT,
236
+ description=(
237
+ "Fetch the Risk Card for a file and ask the assistant to walk the "
238
+ "user through every HIGH-severity signal before suggesting any edit. "
239
+ "Call this from the editor before you start changing an unfamiliar file."
240
+ ),
241
+ arguments=[
242
+ PromptArgument(
243
+ name="path",
244
+ description="Path to the file (absolute or repo-relative).",
245
+ required=True,
246
+ ),
247
+ ],
248
+ ),
249
+ Prompt(
250
+ name=_POSTMORTEM,
251
+ description=(
252
+ "Fetch a commit's metadata and WhyCode classification and ask the "
253
+ "assistant to draft a concise incident summary suitable for a "
254
+ "postmortem document, citing specific evidence SHAs."
255
+ ),
256
+ arguments=[
257
+ PromptArgument(
258
+ name="sha",
259
+ description="Commit SHA (full or short) to summarise.",
260
+ required=True,
261
+ ),
262
+ ],
263
+ ),
264
+ Prompt(
265
+ name=_PR_BRIEFING,
266
+ description=(
267
+ "Fetch the WhyCode risk briefing for files changed against a base "
268
+ "ref and ask the assistant to summarise it for a PR reviewer in "
269
+ "3-5 bullets, emphasising HANDLE WITH CARE files."
270
+ ),
271
+ arguments=[
272
+ PromptArgument(
273
+ name="base",
274
+ description="Base ref to diff against (e.g. origin/main, main, HEAD~1).",
275
+ required=True,
276
+ ),
277
+ ],
278
+ ),
279
+ )
280
+
281
+
282
+ def _missing_arg(name: str, arg: str) -> GetPromptResult:
283
+ """Render a friendly error as a user-role message, so the host displays it."""
284
+ text = f"WhyCode prompt {name!r} requires the {arg!r} argument."
285
+ return GetPromptResult(
286
+ description=text,
287
+ messages=[
288
+ PromptMessage(role="user", content=TextContent(type="text", text=text)),
289
+ ],
290
+ )
291
+
292
+
293
+ def _git_error(name: str, exc: gf.GitError) -> GetPromptResult:
294
+ text = f"WhyCode prompt {name!r} could not run: {exc}"
295
+ return GetPromptResult(
296
+ description=text,
297
+ messages=[
298
+ PromptMessage(role="user", content=TextContent(type="text", text=text)),
299
+ ],
300
+ )
301
+
302
+
303
+ def _render_prompt(name: str, arguments: dict[str, str]) -> GetPromptResult:
304
+ if name == _BEFORE_EDIT:
305
+ return _render_before_edit(arguments)
306
+ if name == _POSTMORTEM:
307
+ return _render_postmortem(arguments)
308
+ if name == _PR_BRIEFING:
309
+ return _render_pr_briefing(arguments)
310
+ raise ValueError(f"Unknown prompt: {name}")
311
+
312
+
313
+ def _format_card_for_prompt(card: rc.RiskCard) -> str:
314
+ """Render a Risk Card as plain text fit for embedding in a prompt body."""
315
+ lines: list[str] = []
316
+ lines.append(
317
+ f"file: {card.path}\n"
318
+ f"band: {card.score.band.value}\n"
319
+ f"score: {card.score.value}/100\n"
320
+ f"commits: {card.commit_count}"
321
+ )
322
+ if card.most_recent_subject:
323
+ lines.append(
324
+ f"latest: {card.most_recent_sha} -- {card.most_recent_subject} "
325
+ f"({card.most_recent_author})"
326
+ )
327
+ if not card.signals:
328
+ lines.append("signals: none fired")
329
+ return "\n".join(lines)
330
+ lines.append("signals:")
331
+ for s in card.signals:
332
+ sev = "HIGH" if s.severity >= 4 else "MED" if s.severity == 3 else "LOW"
333
+ lines.append(f" [{sev}] {s.kind.value}: {s.headline}")
334
+ if s.detail:
335
+ lines.append(f" {s.detail}")
336
+ if s.evidence:
337
+ lines.append(f" evidence: {', '.join(s.evidence)}")
338
+ return "\n".join(lines)
339
+
340
+
341
+ def _render_before_edit(arguments: dict[str, str]) -> GetPromptResult:
342
+ path = arguments.get("path")
343
+ if not path:
344
+ return _missing_arg(_BEFORE_EDIT, "path")
345
+ try:
346
+ repo_root, rel = _resolve(path)
347
+ card = rc.build(repo_root, rel)
348
+ except gf.GitError as exc:
349
+ return _git_error(_BEFORE_EDIT, exc)
350
+
351
+ high_signals = [s for s in card.signals if s.severity >= 4]
352
+ body = (
353
+ "WhyCode pulled the following Risk Card from local git history.\n"
354
+ "Before suggesting any edit to this file, walk the user through every "
355
+ "HIGH-severity signal below and ask them to confirm they understand "
356
+ "each one. Quote the headline verbatim and cite the evidence SHAs. "
357
+ "If no HIGH signals fired, say so explicitly and remind the user to "
358
+ "read the diff anyway.\n\n"
359
+ f"{_format_card_for_prompt(card)}\n\n"
360
+ f"high-severity signals: {len(high_signals)}"
361
+ )
362
+ return GetPromptResult(
363
+ description=(
364
+ f"Pre-edit checklist for {card.path}: "
365
+ f"{card.score.band.value} ({card.score.value}/100), "
366
+ f"{len(high_signals)} HIGH-severity signal(s)."
367
+ ),
368
+ messages=[
369
+ PromptMessage(role="user", content=TextContent(type="text", text=body)),
370
+ ],
371
+ )
372
+
373
+
374
+ def _render_postmortem(arguments: dict[str, str]) -> GetPromptResult:
375
+ sha = arguments.get("sha")
376
+ if not sha:
377
+ return _missing_arg(_POSTMORTEM, "sha")
378
+ try:
379
+ repo_root = gf.discover_repo_root(Path.cwd())
380
+ except gf.GitError as exc:
381
+ return _git_error(_POSTMORTEM, exc)
382
+ commit = gf.read_commit(repo_root, sha)
383
+ if commit is None:
384
+ return _git_error(_POSTMORTEM, gf.GitError(f"could not read commit {sha!r}"))
385
+
386
+ classification = gf.classify_commit(commit)
387
+ invariants = gf.extract_invariant_quotes([commit])
388
+ file_changes = gf.files_changed_in(repo_root, commit.sha)
389
+
390
+ badges: list[str] = []
391
+ if classification.incident_flavoured:
392
+ badges.append("incident-flavoured")
393
+ if invariants:
394
+ badges.append(f"states {len(invariants)} invariant(s)")
395
+ if not badges:
396
+ badges.append("no special classification")
397
+
398
+ lines: list[str] = []
399
+ lines.append(f"sha: {commit.sha[:12]}")
400
+ lines.append(f"author: {commit.author_name} <{commit.author_email}>")
401
+ lines.append(f"authored_at: {commit.authored_at.isoformat()}")
402
+ lines.append(f"subject: {commit.subject}")
403
+ lines.append(f"classification: {', '.join(badges)}")
404
+ lines.append(f"files_changed: {len(file_changes)}")
405
+ if commit.body:
406
+ lines.append("body:")
407
+ for raw_line in commit.body.splitlines():
408
+ lines.append(f" {raw_line}")
409
+ if invariants:
410
+ lines.append("invariants stated by this commit:")
411
+ for inv_sha, inv_line in invariants:
412
+ lines.append(f" ({inv_sha[:7]}) {inv_line}")
413
+ if file_changes:
414
+ lines.append("paths touched:")
415
+ for change in file_changes[:20]:
416
+ lines.append(f" {change.path}")
417
+ if len(file_changes) > 20:
418
+ lines.append(f" ... and {len(file_changes) - 20} more")
419
+
420
+ body = (
421
+ "WhyCode pulled the following commit metadata from local git history.\n"
422
+ "Compose a concise incident summary suitable for a postmortem "
423
+ "document. Cover what changed, why (drawing on the commit body), "
424
+ "which files were touched, and any invariants the author stated. "
425
+ "Cite specific evidence SHAs verbatim -- never invent commits not "
426
+ "listed below. Keep it under 200 words; use plain prose, not bullet "
427
+ "lists.\n\n" + "\n".join(lines)
428
+ )
429
+ return GetPromptResult(
430
+ description=(
431
+ f"Postmortem summary for {commit.sha[:12]}: "
432
+ f"{', '.join(badges)}."
433
+ ),
434
+ messages=[
435
+ PromptMessage(role="user", content=TextContent(type="text", text=body)),
436
+ ],
437
+ )
438
+
439
+
440
+ def _render_pr_briefing(arguments: dict[str, str]) -> GetPromptResult:
441
+ base = arguments.get("base")
442
+ if not base:
443
+ return _missing_arg(_PR_BRIEFING, "base")
444
+ try:
445
+ repo_root = gf.discover_repo_root(Path.cwd())
446
+ raw = gf.run_git(repo_root, "diff", "--name-only", f"{base}...HEAD")
447
+ except gf.GitError as exc:
448
+ return _git_error(_PR_BRIEFING, exc)
449
+
450
+ files = [line for line in raw.splitlines() if line.strip()]
451
+ cards: list[rc.RiskCard] = []
452
+ for f in files:
453
+ try:
454
+ cards.append(rc.build(repo_root, f))
455
+ except gf.GitError:
456
+ continue
457
+ cards.sort(key=lambda c: -c.score.value)
458
+
459
+ lines: list[str] = []
460
+ lines.append(f"base: {base}")
461
+ lines.append(f"files_changed: {len(files)}")
462
+ if not cards:
463
+ lines.append("no files with computable risk against this base")
464
+ else:
465
+ lines.append("risk-ranked files (highest first):")
466
+ for c in cards[:20]:
467
+ top = c.signals[0].headline if c.signals else "no flags"
468
+ lines.append(
469
+ f" [{c.score.value:>3}] {c.score.band.value:<20} "
470
+ f"{c.path} -- {top}"
471
+ )
472
+
473
+ body = (
474
+ "WhyCode produced the following risk briefing for files changed "
475
+ "against the base ref. Summarise it for a PR reviewer in 3-5 bullets, "
476
+ "putting HANDLE WITH CARE files first and naming each by path and "
477
+ "top signal. Do not invent risk that is not listed below; if the "
478
+ "briefing is empty, say so honestly.\n\n" + "\n".join(lines)
479
+ )
480
+ handle_with_care = [c for c in cards if c.score.band.value == "HANDLE WITH CARE"]
481
+ return GetPromptResult(
482
+ description=(
483
+ f"PR risk briefing vs {base}: {len(files)} file(s), "
484
+ f"{len(handle_with_care)} HANDLE WITH CARE."
485
+ ),
486
+ messages=[
487
+ PromptMessage(role="user", content=TextContent(type="text", text=body)),
488
+ ],
489
+ )
490
+
491
+
492
+ async def _run(verbose: bool) -> None:
493
+ server = _build_server(verbose=verbose)
494
+ if verbose:
495
+ print(
496
+ "[whycode] MCP server up. Tool calls from the AI will be logged below.",
497
+ file=sys.stderr,
498
+ flush=True,
499
+ )
500
+ async with stdio_server() as (reader, writer):
501
+ await server.run(reader, writer, server.create_initialization_options())
502
+
503
+
504
+ def serve(verbose: bool = False) -> None:
505
+ """Block on the MCP server. Used by ``whycode mcp``."""
506
+ asyncio.run(_run(verbose))
507
+
508
+
509
+ __all__ = ["serve"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whycode-cli
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: Tells you what to be afraid of before you touch a file.
5
5
  Author: Kevin
6
6
  License-Expression: MIT
@@ -26,6 +26,7 @@ tests/test_cli.py
26
26
  tests/test_decisions.py
27
27
  tests/test_git_facts.py
28
28
  tests/test_ignore.py
29
+ tests/test_mcp_prompts.py
29
30
  tests/test_scorer.py
30
31
  tests/test_signals.py
31
32
  tests/test_suppressions.py
@@ -0,0 +1,315 @@
1
+ """Tests for the MCP prompts surface.
2
+
3
+ The prompts are saved-search shortcuts the host editor surfaces as one-click
4
+ actions. Each one composes a static template from local WhyCode data and
5
+ returns it to the client; no outbound network calls happen here, exactly
6
+ like the tool surface today.
7
+
8
+ We exercise:
9
+ - ``list_prompts`` returns all three prompts with the documented argument
10
+ schemas.
11
+ - ``get_prompt(name, args)`` for each prompt returns a ``GetPromptResult``
12
+ whose first ``user`` message embeds the relevant WhyCode payload.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import os
19
+ from collections.abc import Iterator
20
+ from pathlib import Path
21
+
22
+ import pytest
23
+ from mcp.types import GetPromptResult, Prompt
24
+
25
+ from whycode import mcp_server
26
+
27
+
28
+ @pytest.fixture()
29
+ def in_repo(repo) -> Iterator[Path]: # type: ignore[no-untyped-def]
30
+ """Run the test body with cwd inside ``repo.root``.
31
+
32
+ The postmortem and PR-briefing prompts use ``Path.cwd()`` to discover
33
+ the repo root (mirroring how the MCP host launches the server in the
34
+ user's working directory). Tests need to switch cwd before calling.
35
+ """
36
+ cwd = os.getcwd()
37
+ os.chdir(repo.root)
38
+ try:
39
+ yield repo.root
40
+ finally:
41
+ os.chdir(cwd)
42
+
43
+
44
+ def _run(coro): # type: ignore[no-untyped-def]
45
+ return asyncio.run(coro)
46
+
47
+
48
+ def _list_prompts() -> list[Prompt]:
49
+ """Invoke the registered list_prompts handler via the SDK request flow."""
50
+ server = mcp_server._build_server()
51
+ from mcp.types import ListPromptsRequest
52
+
53
+ req = ListPromptsRequest(method="prompts/list")
54
+ handler = server.request_handlers[ListPromptsRequest]
55
+ result = _run(handler(req))
56
+ # ServerResult wraps a ListPromptsResult.
57
+ return list(result.root.prompts)
58
+
59
+
60
+ def _get_prompt(name: str, arguments: dict[str, str]) -> GetPromptResult:
61
+ server = mcp_server._build_server()
62
+ from mcp.types import GetPromptRequest, GetPromptRequestParams
63
+
64
+ req = GetPromptRequest(
65
+ method="prompts/get",
66
+ params=GetPromptRequestParams(name=name, arguments=arguments),
67
+ )
68
+ handler = server.request_handlers[GetPromptRequest]
69
+ result = _run(handler(req))
70
+ return result.root
71
+
72
+
73
+ # ----- list_prompts ---------------------------------------------------------
74
+
75
+
76
+ def test_list_prompts_returns_three_named_prompts() -> None:
77
+ prompts = _list_prompts()
78
+ names = {p.name for p in prompts}
79
+ assert names == {
80
+ "before_edit_checklist",
81
+ "summarise_for_postmortem",
82
+ "risk_briefing_for_pr",
83
+ }
84
+
85
+
86
+ def test_list_prompts_argument_schemas() -> None:
87
+ prompts = {p.name: p for p in _list_prompts()}
88
+
89
+ before = prompts["before_edit_checklist"]
90
+ assert before.arguments is not None
91
+ assert [a.name for a in before.arguments] == ["path"]
92
+ assert before.arguments[0].required is True
93
+
94
+ pm = prompts["summarise_for_postmortem"]
95
+ assert pm.arguments is not None
96
+ assert [a.name for a in pm.arguments] == ["sha"]
97
+ assert pm.arguments[0].required is True
98
+
99
+ pr = prompts["risk_briefing_for_pr"]
100
+ assert pr.arguments is not None
101
+ assert [a.name for a in pr.arguments] == ["base"]
102
+ assert pr.arguments[0].required is True
103
+
104
+
105
+ def test_list_prompts_descriptions_are_vendor_neutral() -> None:
106
+ """Hard rule: prompt text must not name any AI vendor or product.
107
+
108
+ Tokens are split-and-joined so this test source itself stays neutral
109
+ under static greps (the local pre-commit hook scans for the same names
110
+ in plain text, and this file would otherwise self-trip the guard).
111
+ """
112
+ forbidden: set[str] = {
113
+ "cl" + "aude",
114
+ "cur" + "sor",
115
+ "co" + "pilot",
116
+ "cl" + "ine",
117
+ "wind" + "surf",
118
+ "chat" + "gpt",
119
+ "open" + "ai",
120
+ "anthrop" + "ic",
121
+ "gem" + "ini",
122
+ "tab" + "nine",
123
+ "code" + "whisperer",
124
+ }
125
+ for p in _list_prompts():
126
+ haystack = (p.description or "").lower()
127
+ for token in forbidden:
128
+ assert token not in haystack, (
129
+ f"prompt {p.name!r} description names a forbidden vendor token"
130
+ )
131
+
132
+
133
+ # ----- get_prompt: before_edit_checklist ------------------------------------
134
+
135
+
136
+ def _first_user_text(result: GetPromptResult) -> str:
137
+ msg = result.messages[0]
138
+ assert msg.role == "user"
139
+ # PromptMessage.content is a Content union; we always return TextContent.
140
+ content = msg.content
141
+ text = getattr(content, "text", None)
142
+ assert isinstance(text, str)
143
+ return text
144
+
145
+
146
+ def test_before_edit_includes_band_and_signals(in_repo, days_ago) -> None: # type: ignore[no-untyped-def]
147
+ # Build a file with a clear high-severity signal: revert + recent hotfix.
148
+ from tests.conftest import RepoBuilder
149
+
150
+ builder = RepoBuilder(in_repo)
151
+ sha = builder.commit("feature: refund flow", {"refund.py": "1"}, when=days_ago(40))
152
+ builder.revert(sha, when=days_ago(35))
153
+ builder.commit(
154
+ "hotfix: idempotency token regression",
155
+ {"refund.py": "2"},
156
+ body="incident #99\n\nDo not switch this to async.",
157
+ when=days_ago(5),
158
+ )
159
+
160
+ result = _get_prompt("before_edit_checklist", {"path": "refund.py"})
161
+ body = _first_user_text(result)
162
+ assert "refund.py" in body
163
+ # Must include the band label (one of the four).
164
+ assert any(
165
+ band in body
166
+ for band in (
167
+ "HANDLE WITH CARE",
168
+ "READ HISTORY FIRST",
169
+ "WORTH A LOOK",
170
+ "NO FLAGS",
171
+ )
172
+ )
173
+ # Must include at least one signal kind text.
174
+ assert "signals:" in body or "signals: none fired" in body
175
+ # The instruction wrapper has to mention HIGH severity since that's the
176
+ # whole point of the prompt.
177
+ assert "HIGH" in body
178
+ # Description summarises score + band + HIGH count.
179
+ assert result.description is not None
180
+ assert "refund.py" in result.description
181
+
182
+
183
+ def test_before_edit_missing_arg_returns_friendly_message() -> None:
184
+ result = _get_prompt("before_edit_checklist", {})
185
+ body = _first_user_text(result)
186
+ assert "path" in body
187
+ assert "before_edit_checklist" in body
188
+
189
+
190
+ # ----- get_prompt: summarise_for_postmortem --------------------------------
191
+
192
+
193
+ def test_postmortem_includes_commit_metadata(in_repo, days_ago) -> None: # type: ignore[no-untyped-def]
194
+ from tests.conftest import RepoBuilder
195
+
196
+ builder = RepoBuilder(in_repo)
197
+ sha = builder.commit(
198
+ "hotfix: idempotency token regression",
199
+ {"refund.py": "2"},
200
+ body="incident #99\n\nDo not switch this to async; v1 clients break.",
201
+ when=days_ago(5),
202
+ )
203
+
204
+ result = _get_prompt("summarise_for_postmortem", {"sha": sha})
205
+ body = _first_user_text(result)
206
+ assert sha[:12] in body
207
+ assert "hotfix: idempotency token regression" in body
208
+ # Body of the commit (the "why") must be visible to the assistant.
209
+ assert "v1 clients break" in body
210
+ # Classification label must be present (this commit fires incident-flavoured).
211
+ assert "incident-flavoured" in body
212
+ # File touched should be listed.
213
+ assert "refund.py" in body
214
+
215
+
216
+ def test_postmortem_unknown_sha_returns_error(in_repo) -> None: # type: ignore[no-untyped-def]
217
+ result = _get_prompt(
218
+ "summarise_for_postmortem", {"sha": "ffffffffffffffffffffffffffffffffffffffff"}
219
+ )
220
+ body = _first_user_text(result)
221
+ assert "could not run" in body or "could not read" in body
222
+
223
+
224
+ def test_postmortem_missing_arg_returns_friendly_message(in_repo) -> None: # type: ignore[no-untyped-def]
225
+ result = _get_prompt("summarise_for_postmortem", {})
226
+ body = _first_user_text(result)
227
+ assert "sha" in body
228
+
229
+
230
+ # ----- get_prompt: risk_briefing_for_pr ------------------------------------
231
+
232
+
233
+ def test_pr_briefing_lists_changed_files(in_repo, days_ago) -> None: # type: ignore[no-untyped-def]
234
+ from tests.conftest import RepoBuilder
235
+
236
+ builder = RepoBuilder(in_repo)
237
+ builder.commit("init", {"a.py": "1", "b.py": "1"}, when=days_ago(60))
238
+ sha = builder.commit("feature: A", {"a.py": "2"}, when=days_ago(40))
239
+ builder.revert(sha, when=days_ago(35))
240
+ builder.commit(
241
+ "hotfix: regression in a",
242
+ {"a.py": "3"},
243
+ body="incident #42",
244
+ when=days_ago(5),
245
+ )
246
+ builder.commit("docs: tweak b", {"b.py": "2"}, when=days_ago(2))
247
+
248
+ result = _get_prompt("risk_briefing_for_pr", {"base": "HEAD~3"})
249
+ body = _first_user_text(result)
250
+ assert "base: HEAD~3" in body
251
+ assert "a.py" in body
252
+ # The instruction must mention HANDLE WITH CARE so the assistant prioritises.
253
+ assert "HANDLE WITH CARE" in body
254
+
255
+
256
+ def test_pr_briefing_empty_diff_returns_honest_message(in_repo) -> None: # type: ignore[no-untyped-def]
257
+ from tests.conftest import RepoBuilder
258
+
259
+ builder = RepoBuilder(in_repo)
260
+ builder.commit("init", {"a.py": "1"})
261
+ result = _get_prompt("risk_briefing_for_pr", {"base": "HEAD"})
262
+ body = _first_user_text(result)
263
+ assert "files_changed: 0" in body or "no files" in body
264
+
265
+
266
+ def test_pr_briefing_missing_arg_returns_friendly_message(in_repo) -> None: # type: ignore[no-untyped-def]
267
+ result = _get_prompt("risk_briefing_for_pr", {})
268
+ body = _first_user_text(result)
269
+ assert "base" in body
270
+
271
+
272
+ # ----- privacy: server stays read-only --------------------------------------
273
+
274
+
275
+ def test_prompts_make_no_outbound_network_calls(in_repo, days_ago, monkeypatch) -> None: # type: ignore[no-untyped-def]
276
+ """Belt-and-braces: any attempt to dial an outbound IP socket should
277
+ blow up the test rather than silently leak data. The prompts surface
278
+ must rely on local git only.
279
+
280
+ Local Unix-domain sockets (used internally by asyncio) and loopback
281
+ are intentionally allowed; we only guard the routes that could leave
282
+ the machine."""
283
+ import socket as socket_mod
284
+
285
+ real_socket = socket_mod.socket
286
+
287
+ class _Tripwire(real_socket): # type: ignore[misc, valid-type]
288
+ def connect(self, address, *args, **kwargs): # type: ignore[no-untyped-def]
289
+ raise AssertionError(
290
+ f"prompts must not open outbound sockets (got connect to {address!r})"
291
+ )
292
+
293
+ def _factory(*args, **kwargs): # type: ignore[no-untyped-def]
294
+ family = args[0] if args else kwargs.get("family", socket_mod.AF_INET)
295
+ if family in (socket_mod.AF_INET, socket_mod.AF_INET6):
296
+ return _Tripwire(*args, **kwargs)
297
+ return real_socket(*args, **kwargs)
298
+
299
+ monkeypatch.setattr(socket_mod, "socket", _factory)
300
+
301
+ from tests.conftest import RepoBuilder
302
+
303
+ builder = RepoBuilder(in_repo)
304
+ builder.commit("init", {"a.py": "1"}, when=days_ago(20))
305
+ builder.commit(
306
+ "hotfix: regression",
307
+ {"a.py": "2"},
308
+ body="incident #1",
309
+ when=days_ago(2),
310
+ )
311
+
312
+ # Each prompt is rendered without any IP socket connect.
313
+ _ = _get_prompt("before_edit_checklist", {"path": "a.py"})
314
+ _ = _get_prompt("summarise_for_postmortem", {"sha": "HEAD"})
315
+ _ = _get_prompt("risk_briefing_for_pr", {"base": "HEAD~1"})
@@ -1,204 +0,0 @@
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"]
File without changes
File without changes
File without changes