gitpro 0.3.1__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.
- gitpro/__init__.py +4 -0
- gitpro/__main__.py +5 -0
- gitpro/agents.py +1928 -0
- gitpro/approval_policy.py +151 -0
- gitpro/args.py +82 -0
- gitpro/audit.py +800 -0
- gitpro/change_contract.py +117 -0
- gitpro/changes.py +853 -0
- gitpro/cli.py +499 -0
- gitpro/clone.py +505 -0
- gitpro/completion.py +407 -0
- gitpro/config.py +517 -0
- gitpro/devcontainer.py +442 -0
- gitpro/discover.py +1579 -0
- gitpro/doctor.py +572 -0
- gitpro/envcmd.py +370 -0
- gitpro/example.toml +102 -0
- gitpro/files.py +139 -0
- gitpro/forge.py +1244 -0
- gitpro/guard.py +1121 -0
- gitpro/health.py +1241 -0
- gitpro/hot.py +38 -0
- gitpro/jsonfile.py +195 -0
- gitpro/jsonout.py +26 -0
- gitpro/keys.py +59 -0
- gitpro/lint.py +50 -0
- gitpro/lost.py +874 -0
- gitpro/mcp.py +1055 -0
- gitpro/operation_contract.py +178 -0
- gitpro/operation_io.py +256 -0
- gitpro/operation_reconcile.py +191 -0
- gitpro/operations.py +841 -0
- gitpro/pin.py +89 -0
- gitpro/plan.py +199 -0
- gitpro/policy.py +1808 -0
- gitpro/prompt.py +304 -0
- gitpro/render.py +474 -0
- gitpro/resolve.py +898 -0
- gitpro/scan.py +358 -0
- gitpro/scanroots.py +146 -0
- gitpro/setup.py +260 -0
- gitpro/trailers.py +218 -0
- gitpro/tui.py +178 -0
- gitpro/undo.py +63 -0
- gitpro/view.py +155 -0
- gitpro-0.3.1.dist-info/METADATA +103 -0
- gitpro-0.3.1.dist-info/RECORD +50 -0
- gitpro-0.3.1.dist-info/WHEEL +4 -0
- gitpro-0.3.1.dist-info/entry_points.txt +2 -0
- gitpro-0.3.1.dist-info/licenses/LICENSE +21 -0
gitpro/mcp.py
ADDED
|
@@ -0,0 +1,1055 @@
|
|
|
1
|
+
"""`gitpro mcp`: read-only by default, with operator-opted-in agent operation execution.
|
|
2
|
+
|
|
3
|
+
The server speaks newline-delimited JSON-RPC 2.0 on stdin/stdout, by hand with the stdlib. Every tool
|
|
4
|
+
runs with stdout redirected to stderr, so a stray print never corrupts the stream; responses go to the
|
|
5
|
+
saved stdout handle. Preflight may explicitly opt into network reads. Execution tools require operator
|
|
6
|
+
opt-in; persistent changes additionally require human approval or an operator-selected scoped policy.
|
|
7
|
+
No tool records human approval or returns credential values.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import contextlib
|
|
13
|
+
import importlib
|
|
14
|
+
import inspect
|
|
15
|
+
import io
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shlex
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
import traceback
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Callable, Dict, IO, List, Optional, Tuple
|
|
27
|
+
|
|
28
|
+
from . import __version__
|
|
29
|
+
from .config import ConfigError
|
|
30
|
+
from .operation_contract import REQUEST_SCHEMA, OperationError, unique_members
|
|
31
|
+
from .change_contract import REQUEST_SCHEMA as CHANGE_REQUEST_SCHEMA
|
|
32
|
+
|
|
33
|
+
PROTOCOL_VERSIONS = ("2024-11-05", "2025-03-26", "2025-06-18", "2025-11-25") # newest last
|
|
34
|
+
STRUCTURED_SINCE = "2025-06-18" # structuredContent only for clients at this version or newer
|
|
35
|
+
MAX_ROOTS = 20
|
|
36
|
+
CLI_TIMEOUT = 30 # seconds for `claude mcp add|remove`
|
|
37
|
+
|
|
38
|
+
INSTRUCTIONS = ("gitpro's tools are read-only: they tell you which git identity (name, email, SSH key, forge "
|
|
39
|
+
"account) a repository uses, why, and whether a commit would be blocked by gitpro's guard. They "
|
|
40
|
+
"never change anything. To change something, show the user the fix command a tool returns and "
|
|
41
|
+
"let them run it.")
|
|
42
|
+
ANNOTATIONS = {"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True, "openWorldHint": False}
|
|
43
|
+
|
|
44
|
+
PARSE_ERROR, INVALID_REQUEST, NO_METHOD, INVALID_PARAMS, INTERNAL_ERROR = -32700, -32600, -32601, -32602, -32603
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ToolError(Exception):
|
|
48
|
+
"""A gitpro error a tool reports as a result with isError (the server carries on)."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class RpcError(Exception):
|
|
52
|
+
"""A JSON-RPC error response."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, code: int, message: str):
|
|
55
|
+
super().__init__(message)
|
|
56
|
+
self.code, self.message = code, message
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ---------------------------------------------------------------- tool helpers
|
|
60
|
+
|
|
61
|
+
class Ctx:
|
|
62
|
+
"""What a tool gets besides its arguments: -c and the config, loaded on first use (per tools/call)."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, cfg_path: Optional[Path] = None, allow_execution: bool = False,
|
|
65
|
+
approval_policy: Optional[Path] = None):
|
|
66
|
+
self.cfg_path = Path(cfg_path) if cfg_path else None
|
|
67
|
+
self.home = Path.home().resolve()
|
|
68
|
+
self._cfg = None
|
|
69
|
+
self.allow_execution = allow_execution
|
|
70
|
+
self.approval_policy = approval_policy
|
|
71
|
+
|
|
72
|
+
def config(self):
|
|
73
|
+
"""The config; ConfigError when there is none or it is invalid."""
|
|
74
|
+
from . import config
|
|
75
|
+
if self._cfg is None:
|
|
76
|
+
path, how = config.find(self.cfg_path, self.home)
|
|
77
|
+
self._cfg = config.load_all(path, how)
|
|
78
|
+
return self._cfg
|
|
79
|
+
|
|
80
|
+
def config_or_none(self):
|
|
81
|
+
try:
|
|
82
|
+
return self.config()
|
|
83
|
+
except ConfigError:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _absolute(raw: str) -> Path:
|
|
88
|
+
p = Path(raw).expanduser()
|
|
89
|
+
return (p if p.is_absolute() else Path.cwd() / p).resolve()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _readable_dir(p: Path) -> bool:
|
|
93
|
+
return p.is_dir() and os.access(p, os.R_OK | os.X_OK)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _dir(raw: str) -> Path:
|
|
97
|
+
"""A client path that must be a directory the user can read."""
|
|
98
|
+
p = _absolute(raw)
|
|
99
|
+
if not _readable_dir(p):
|
|
100
|
+
raise ToolError(f"{raw}: not a directory")
|
|
101
|
+
return p
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _repo(raw: str) -> Path:
|
|
105
|
+
"""A client path that must be (inside) a git repository."""
|
|
106
|
+
from . import resolve
|
|
107
|
+
p = _absolute(raw)
|
|
108
|
+
if not _readable_dir(p) or resolve.is_repo(p) is None:
|
|
109
|
+
raise ToolError(f"{raw}: not a git repository")
|
|
110
|
+
return p
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _envelope(command: str, obj: dict) -> dict:
|
|
114
|
+
"""{"ok", "command", **obj}; an "ok" inside obj wins (as jsonout.emit)."""
|
|
115
|
+
out = {"ok": obj.get("ok", True), "command": command}
|
|
116
|
+
out.update((k, v) for k, v in obj.items() if k not in ("ok", "command"))
|
|
117
|
+
return out
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _cli(command: str, argv: List[str], cfg_path: Optional[Path] = None) -> dict:
|
|
121
|
+
"""Run `gitpro <command> <argv>` in-process (the parser is built per call, so extensions and the
|
|
122
|
+
current HOME apply) and return its --json object. An error object becomes a ToolError."""
|
|
123
|
+
from . import cli
|
|
124
|
+
from .args import guarded
|
|
125
|
+
parser, subs = cli.build_parser()
|
|
126
|
+
if cfg_path and "-c" in subs[command]._option_string_actions:
|
|
127
|
+
argv = ["-c", str(cfg_path), *argv]
|
|
128
|
+
buf = io.StringIO()
|
|
129
|
+
with contextlib.redirect_stdout(buf):
|
|
130
|
+
try:
|
|
131
|
+
args = parser.parse_args([command, *argv])
|
|
132
|
+
rc = guarded(args.func, args)
|
|
133
|
+
except SystemExit as e:
|
|
134
|
+
raise ToolError(f"gitpro {command}: invalid arguments (exit {e.code})") from None
|
|
135
|
+
text = buf.getvalue()
|
|
136
|
+
if not text.strip():
|
|
137
|
+
raise ToolError(f"gitpro {command} printed nothing (exit {rc})")
|
|
138
|
+
obj = json.loads(text)
|
|
139
|
+
if obj.get("ok") is False and obj.get("error"):
|
|
140
|
+
raise ToolError(str(obj["error"]))
|
|
141
|
+
return obj
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------- tools
|
|
145
|
+
|
|
146
|
+
def tool_which(args: dict, ctx: Ctx) -> dict:
|
|
147
|
+
return _cli("which", ["--json", "--", str(_repo(args["path"]))])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def tool_why(args: dict, ctx: Ctx) -> dict:
|
|
151
|
+
from . import resolve
|
|
152
|
+
resolve.require_git()
|
|
153
|
+
repo = _repo(args["path"])
|
|
154
|
+
cfg = ctx.config()
|
|
155
|
+
rep = resolve.compare(repo, cfg, remote=args.get("remote"), home=ctx.home)
|
|
156
|
+
data = rep.to_json()
|
|
157
|
+
data.pop("ok")
|
|
158
|
+
return {"ok": rep.ok(), "command": "why", "repo": data.pop("repo"),
|
|
159
|
+
"config": {"path": str(cfg.path), "how": cfg.how}, **data}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
GUARD_CODES = ("override", "pin_stale", "no_identity", "env_override") # 10 §2 step 5
|
|
163
|
+
CAUSE_CODES = ("url_form", "override", "pin_stale", "global_override", "not_applied", "env_override")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _problem(code: str, message: str, fix: Optional[str]) -> dict:
|
|
167
|
+
return {"code": code, "message": message, "fix": fix}
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _who(rep) -> str:
|
|
171
|
+
e = rep.expected
|
|
172
|
+
return f"identity {e.id or '[default]'} <{e.email}>"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _where(rep) -> str:
|
|
176
|
+
m = rep.expected.match
|
|
177
|
+
return f"{m.remote} ({m.url})" if m.remote and m.url else str(rep.repo)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _identity_problems(rep, cfg, args: dict) -> List[dict]:
|
|
181
|
+
"""The guard's committer/author judgement (06 §2): committer must be the expected email; the author
|
|
182
|
+
only counts when it is one of the user's own emails."""
|
|
183
|
+
from . import resolve
|
|
184
|
+
expected = rep.expected.email
|
|
185
|
+
committer = args.get("committer_email") or rep.effective.email.value or ""
|
|
186
|
+
author = args.get("author_email") or committer
|
|
187
|
+
if expected is None:
|
|
188
|
+
return []
|
|
189
|
+
out = []
|
|
190
|
+
bad_committer, bad_author = resolve.identity_conflicts(expected, resolve.own_emails(cfg), author, committer)
|
|
191
|
+
if bad_committer:
|
|
192
|
+
cause = next((p for p in rep.problems if p.code in CAUSE_CODES), None)
|
|
193
|
+
msg = (f"this commit would be committed as {committer or '(no email)'} but {_where(rep)} belongs to "
|
|
194
|
+
f"{_who(rep)}" + (f". cause: {cause.message}" if cause else ""))
|
|
195
|
+
fix = resolve._shown_fix(cause) if cause else f"gitpro why {shlex.quote(str(rep.repo))}"
|
|
196
|
+
out.append(_problem("committer", msg, fix))
|
|
197
|
+
a = author.casefold()
|
|
198
|
+
if bad_author and a != committer.casefold(): # same as the committer: reported above
|
|
199
|
+
who = f"{rep.expected.name} <{expected}>"
|
|
200
|
+
out.append(_problem("author", f"the author would be {author}, your own other email; {_where(rep)} belongs "
|
|
201
|
+
f"to {_who(rep)}", f"git commit --author={shlex.quote(who)} ..."))
|
|
202
|
+
return out
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _trailer_problems(repo: Path, rep, cfg, message: str, warnings: List[str]) -> List[dict]:
|
|
206
|
+
"""Co-Authored-By policy for the message (08's trailers module); a warning when it cannot be checked."""
|
|
207
|
+
try:
|
|
208
|
+
from . import trailers
|
|
209
|
+
except ImportError:
|
|
210
|
+
warnings.append("message not checked: trailer rules need gitpro's audit package")
|
|
211
|
+
return []
|
|
212
|
+
try:
|
|
213
|
+
r = subprocess.run(["git", "-C", str(repo), "interpret-trailers", "--parse"], input=message,
|
|
214
|
+
capture_output=True, text=True, timeout=10)
|
|
215
|
+
pairs = [tuple(s.strip() for s in ln.split(":", 1)) for ln in r.stdout.splitlines() if ":" in ln]
|
|
216
|
+
# 08 §11: trailers.policy_for(identity, default), trailers.check(trailers, policy) -> list[str]
|
|
217
|
+
policy = _call_spec(trailers.policy_for, [("identity", rep.expected.match.identity),
|
|
218
|
+
("default", cfg.default)])
|
|
219
|
+
if hasattr(trailers, "violations"): # 08: (message, fix) pairs, so a require violation gets its own fix
|
|
220
|
+
found = [(str(m), str(f)) for m, f in trailers.violations(pairs, policy)]
|
|
221
|
+
else:
|
|
222
|
+
found = [(str(m), "remove the trailer from the message")
|
|
223
|
+
for m in _call_spec(trailers.check, [("trailers", pairs), ("policy", policy)]) or []]
|
|
224
|
+
except Exception as e: # noqa: BLE001 - another package's checker must not break the tool
|
|
225
|
+
warnings.append(f"message not checked: {type(e).__name__}")
|
|
226
|
+
return []
|
|
227
|
+
return [_problem("trailer", m, f) for m, f in found]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def tool_check_commit(args: dict, ctx: Ctx) -> dict:
|
|
231
|
+
from . import resolve
|
|
232
|
+
resolve.require_git()
|
|
233
|
+
repo = _repo(args["path"])
|
|
234
|
+
cfg = ctx.config()
|
|
235
|
+
rep = resolve.compare(repo, cfg, remote=args.get("remote"), home=ctx.home)
|
|
236
|
+
probs = _identity_problems(rep, cfg, args)
|
|
237
|
+
probs += [_problem(p.code, p.message, resolve._shown_fix(p) or None) for p in rep.problems
|
|
238
|
+
if p.code in GUARD_CODES]
|
|
239
|
+
warnings: List[str] = []
|
|
240
|
+
if args.get("message") is not None:
|
|
241
|
+
probs += _trailer_problems(rep.repo, rep, cfg, args["message"], warnings)
|
|
242
|
+
mode = cfg.agents.hook
|
|
243
|
+
out = {"ok": not probs, "command": "check_commit", "would_block": bool(probs) and mode == "block",
|
|
244
|
+
"mode": mode, "problems": probs, "reason": probs[0]["message"] if probs else None,
|
|
245
|
+
"fix": probs[0]["fix"] if probs else None}
|
|
246
|
+
if warnings:
|
|
247
|
+
out["warnings"] = warnings
|
|
248
|
+
return out
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _capped(values) -> List[str]:
|
|
252
|
+
values = list(values or ())
|
|
253
|
+
if len(values) > MAX_ROOTS:
|
|
254
|
+
raise RpcError(INVALID_PARAMS, f"at most {MAX_ROOTS} entries")
|
|
255
|
+
return values
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def tool_scan(args: dict, ctx: Ctx) -> dict:
|
|
259
|
+
roots = [str(_dir(r)) for r in _capped(args.get("roots"))]
|
|
260
|
+
argv = ["--json"]
|
|
261
|
+
for r in _capped(args.get("repos")):
|
|
262
|
+
argv += ["--repo", str(_dir(r))]
|
|
263
|
+
if roots:
|
|
264
|
+
argv += ["--", *roots]
|
|
265
|
+
return _cli("scan", argv, ctx.cfg_path)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def tool_env(args: dict, ctx: Ctx) -> dict:
|
|
269
|
+
repo = _repo(args["path"])
|
|
270
|
+
return _cli("env", ["--json", "--shell", args.get("shell", "sh"), "--", str(repo)], ctx.cfg_path)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def tool_preflight(args: dict, ctx: Ctx) -> dict:
|
|
274
|
+
from .operations import preflight
|
|
275
|
+
return preflight(args["request"], ctx.cfg_path, online=args.get("online", False))
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def tool_run_operation(args: dict, ctx: Ctx) -> dict:
|
|
279
|
+
from .operations import execute
|
|
280
|
+
return execute(args["request"], args["plan_token"], args["idempotency_key"], ctx.cfg_path,
|
|
281
|
+
online=args.get("online", False), authorized=ctx.allow_execution)
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def tool_operation_status(args: dict, ctx: Ctx) -> dict:
|
|
285
|
+
from .operations import status
|
|
286
|
+
return status(args["idempotency_key"])
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def tool_propose_change(args: dict, ctx: Ctx) -> dict:
|
|
290
|
+
from .changes import propose
|
|
291
|
+
return propose(args["request"], ctx.cfg_path, policy_path=ctx.approval_policy)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def tool_change_status(args: dict, ctx: Ctx) -> dict:
|
|
295
|
+
from .changes import status
|
|
296
|
+
return status(args["plan_id"])
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def tool_apply_change(args: dict, ctx: Ctx) -> dict:
|
|
300
|
+
from .changes import apply
|
|
301
|
+
return apply(args["plan_id"], authorized=ctx.allow_execution, raw=args.get("request"),
|
|
302
|
+
policy_path=ctx.approval_policy, cfg_path=ctx.cfg_path)
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def tool_recover_change(args: dict, ctx: Ctx) -> dict:
|
|
306
|
+
from .changes import apply
|
|
307
|
+
return apply(args["plan_id"], authorized=ctx.allow_execution, recover=True)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def tool_reconcile_operation(args: dict, ctx: Ctx) -> dict:
|
|
311
|
+
from .operation_reconcile import reconcile
|
|
312
|
+
return reconcile(args["idempotency_key"], online=args.get("online", False))
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
LOST_KINDS = ("unpushed", "merged_gone", "stash", "dirty", "no_remote", "duplicate", "stale", "empty") # offline
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _scan_config(ctx: Ctx):
|
|
319
|
+
"""(full config or None, its [scan] table or None), as `gitpro lost` reads them: a config whose other
|
|
320
|
+
tables are broken still gives its [scan]."""
|
|
321
|
+
from . import config
|
|
322
|
+
cfg = ctx.config_or_none()
|
|
323
|
+
if cfg is not None:
|
|
324
|
+
return cfg, getattr(cfg, "scan", None)
|
|
325
|
+
load_scan = getattr(config, "load_scan", None)
|
|
326
|
+
if load_scan is None:
|
|
327
|
+
return None, None
|
|
328
|
+
try:
|
|
329
|
+
path, _how = config.find(ctx.cfg_path, ctx.home)
|
|
330
|
+
return None, load_scan(path)
|
|
331
|
+
except ConfigError:
|
|
332
|
+
return None, None
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _lost_roots(dirs: List[Path], scan_cfg) -> Tuple[List[Path], List[str], str]:
|
|
336
|
+
"""(roots, exclude, roots_source) as `gitpro lost` picks them (03 §2.1, 02's scan.effective_roots)."""
|
|
337
|
+
try:
|
|
338
|
+
from .scan import effective_roots # type: ignore[attr-defined] (added by 02)
|
|
339
|
+
except ImportError:
|
|
340
|
+
effective_roots = None
|
|
341
|
+
if effective_roots is not None:
|
|
342
|
+
roots, exclude, source = effective_roots([str(d) for d in dirs], scan_cfg)
|
|
343
|
+
return [Path(r) for r in roots], list(exclude), str(source)
|
|
344
|
+
if dirs:
|
|
345
|
+
raw, source = [str(d) for d in dirs], "args"
|
|
346
|
+
elif scan_cfg is not None and getattr(scan_cfg, "roots", None):
|
|
347
|
+
raw, source = list(scan_cfg.roots), "config"
|
|
348
|
+
else:
|
|
349
|
+
raw, source = ["~"], "default"
|
|
350
|
+
out: List[Path] = []
|
|
351
|
+
for r in raw:
|
|
352
|
+
p = Path(r).expanduser().resolve()
|
|
353
|
+
if p not in out:
|
|
354
|
+
out.append(p)
|
|
355
|
+
return out, list(getattr(scan_cfg, "exclude", None) or ()), source
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def tool_lost(args: dict, ctx: Ctx) -> dict:
|
|
359
|
+
from . import lost # type: ignore[attr-defined]
|
|
360
|
+
dirs = [_dir(r) for r in _capped(args.get("roots"))]
|
|
361
|
+
cfg, scan_cfg = _scan_config(ctx)
|
|
362
|
+
roots, exclude, source = _lost_roots(dirs, scan_cfg)
|
|
363
|
+
offline = getattr(lost, "OFFLINE_KINDS", LOST_KINDS)
|
|
364
|
+
kinds = set(args.get("only") or offline) # a set: 03 tests membership and intersects it
|
|
365
|
+
stale = getattr(scan_cfg, "stale_days", None)
|
|
366
|
+
rep = lost.collect(roots, exclude, cfg, online=False, kinds=kinds,
|
|
367
|
+
stale_days=int(stale) if stale else 180, roots_source=source)
|
|
368
|
+
return _envelope("lost", rep)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _call_spec(fn: Callable, values: List[Tuple[str, object]]):
|
|
372
|
+
"""fn called with values, which follow the spec's positional order (name, value). Tolerant of how the
|
|
373
|
+
other package declares them: keyword-only parameters get their value by name, and positional ones by
|
|
374
|
+
name when every name matches, else by position."""
|
|
375
|
+
try:
|
|
376
|
+
params = list(inspect.signature(fn).parameters.values())
|
|
377
|
+
except (TypeError, ValueError):
|
|
378
|
+
return fn(*[v for _, v in values])
|
|
379
|
+
named = dict(values)
|
|
380
|
+
positional = [p for p in params if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)]
|
|
381
|
+
kwargs = {p.name: named[p.name] for p in params if p.kind == p.KEYWORD_ONLY and p.name in named}
|
|
382
|
+
if all(p.name in named for p in positional):
|
|
383
|
+
return fn(*[named[p.name] for p in positional], **kwargs)
|
|
384
|
+
rest = [v for k, v in values if k not in kwargs]
|
|
385
|
+
return fn(*rest[:len(positional)], **kwargs)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def tool_audit(args: dict, ctx: Ctx) -> dict:
|
|
389
|
+
from . import audit # type: ignore[attr-defined]
|
|
390
|
+
repo = _repo(args["path"])
|
|
391
|
+
rng = args.get("range")
|
|
392
|
+
if rng is not None and rng.startswith("-"):
|
|
393
|
+
raise ToolError("range must not start with '-'")
|
|
394
|
+
# 08 §11: audit.collect(repo, cfg, range, all) -> dict
|
|
395
|
+
rep = _call_spec(audit.collect, [("repo", repo), ("cfg", ctx.config()), ("range", rng),
|
|
396
|
+
("all", bool(args.get("all")))])
|
|
397
|
+
return _envelope("audit", rep)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def tool_identities(args: dict, ctx: Ctx) -> dict:
|
|
401
|
+
from . import config
|
|
402
|
+
cfg = ctx.config()
|
|
403
|
+
d = cfg.default
|
|
404
|
+
return {"ok": True, "command": "identities", "config": str(cfg.path),
|
|
405
|
+
"default": {"name": d.name, "email": d.email, "gh_account": d.gh_account,
|
|
406
|
+
"glab_account": d.glab_account, "clone_dir": d.clone_dir, "sign": d.sign},
|
|
407
|
+
"identities": [{"id": i.id, "name": i.name, "email": i.email, "host": i.host, "owners": list(i.owners),
|
|
408
|
+
"ssh_alias": i.ssh_alias, "gh_account": i.gh_account, "glab_account": i.glab_account,
|
|
409
|
+
"clone_dir": i.clone_dir, "sign": config.signs(i, d)} for i in cfg.identities]}
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------- the registry
|
|
413
|
+
|
|
414
|
+
def _schema(props: dict, required: Tuple[str, ...] = ()) -> dict:
|
|
415
|
+
return {"type": "object", "properties": props, "required": list(required), "additionalProperties": False}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _string(desc: str, **extra) -> dict:
|
|
419
|
+
return {"type": "string", "minLength": 1, "description": desc, **extra}
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _strings(desc: str, **items) -> dict:
|
|
423
|
+
return {"type": "array", "items": {"type": "string", "minLength": 1, **items}, "maxItems": MAX_ROOTS,
|
|
424
|
+
"description": desc}
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
PATH = _string("a directory inside the git repository (absolute, or relative to the server's directory)")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
class Tool:
|
|
431
|
+
"""One MCP tool; `fn(args, ctx) -> dict` is looked up per call, so tests can replace it."""
|
|
432
|
+
|
|
433
|
+
def __init__(self, name: str, description: str, schema: dict, fn: Callable[[dict, Ctx], dict],
|
|
434
|
+
module: Optional[str] = None, execution: bool = False):
|
|
435
|
+
self.name, self.description, self.schema, self.fn, self.module = name, description, schema, fn, module
|
|
436
|
+
self.execution = execution
|
|
437
|
+
|
|
438
|
+
def available(self) -> bool:
|
|
439
|
+
"""False when the module behind the tool is not installed (or blocked with sys.modules[...] = None)."""
|
|
440
|
+
if self.module is None:
|
|
441
|
+
return True
|
|
442
|
+
try:
|
|
443
|
+
importlib.import_module(self.module)
|
|
444
|
+
except ImportError:
|
|
445
|
+
return False
|
|
446
|
+
return True
|
|
447
|
+
|
|
448
|
+
def listing(self) -> dict:
|
|
449
|
+
return {"name": self.name, "description": self.description, "inputSchema": self.schema,
|
|
450
|
+
"annotations": ({**ANNOTATIONS, "readOnlyHint": False, "destructiveHint": True, "openWorldHint": True}
|
|
451
|
+
if self.execution else {**ANNOTATIONS, "openWorldHint":
|
|
452
|
+
self.name in ("preflight", "reconcile_operation")})}
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
TOOLS: Dict[str, Tool] = {t.name: t for t in (
|
|
456
|
+
Tool("which_identity", "The git identity settings (user.name, user.email, signing, core.sshCommand) a repo "
|
|
457
|
+
"uses right now and where each comes from; same as `gitpro which --json`.",
|
|
458
|
+
_schema({"path": PATH}, ("path",)), tool_which),
|
|
459
|
+
Tool("why", "Which gitpro identity a repo should use, which it really uses, why, and the fix command for "
|
|
460
|
+
"every problem; same as `gitpro why --json`.",
|
|
461
|
+
_schema({"path": PATH, "remote": _string("judge against this remote (name or url) instead of origin")},
|
|
462
|
+
("path",)), tool_why),
|
|
463
|
+
Tool("check_commit", "Would gitpro's guard block this commit? Judges the committer (default: the repo's "
|
|
464
|
+
"effective email) and author emails against the repo's identity and returns the reason and fix.",
|
|
465
|
+
_schema({"path": PATH, "author_email": _string("the commit's author email (default: the committer)"),
|
|
466
|
+
"committer_email": _string("the committer email (default: what git would use in the repo)"),
|
|
467
|
+
"remote": _string("judge against this remote (name or url) instead of origin"),
|
|
468
|
+
"message": {"type": "string", "description": "the commit message, for the Co-Authored-By rules"}},
|
|
469
|
+
("path",)), tool_check_commit),
|
|
470
|
+
Tool("scan", "List repositories under the given roots (or only the given repos) and the identity git uses "
|
|
471
|
+
"in each; never fixes anything. Same as `gitpro scan --json`.",
|
|
472
|
+
_schema({"roots": _strings("directories to search (default: the configured roots)"),
|
|
473
|
+
"repos": _strings("only these repositories, not the repos under them")}), tool_scan),
|
|
474
|
+
Tool("env_for", "Shell lines that set GH_TOKEN etc. for the repo's identity: the commands that fetch the "
|
|
475
|
+
"tokens, never their values. Same as `gitpro env --json`.",
|
|
476
|
+
_schema({"path": PATH, "shell": {"type": "string", "enum": ["sh", "fish", "pwsh"],
|
|
477
|
+
"description": "shell syntax (default: sh)"}}, ("path",)),
|
|
478
|
+
tool_env, "gitpro.envcmd"),
|
|
479
|
+
Tool("lost", "Unpushed branches, stashes, dirty trees, repos without a remote and duplicate clones under "
|
|
480
|
+
"the roots (offline checks only). Same as `gitpro lost --json`.",
|
|
481
|
+
_schema({"roots": _strings("directories to search (default: the configured roots)"),
|
|
482
|
+
"only": _strings("only these kinds", enum=list(LOST_KINDS))}), tool_lost, "gitpro.lost"),
|
|
483
|
+
Tool("audit", "Commits whose author or committer is not the repo's identity, and agent trailers; never "
|
|
484
|
+
"rewrites. Same as `gitpro audit --json`.",
|
|
485
|
+
_schema({"path": PATH, "range": _string("a git revision range (default: the unpushed commits)"),
|
|
486
|
+
"all": {"type": "boolean", "description": "every commit on the branch"}}, ("path",)),
|
|
487
|
+
tool_audit, "gitpro.audit"),
|
|
488
|
+
Tool("identities", "The configured identities (name, email, host, owners, forge accounts, signing); no key "
|
|
489
|
+
"contents, no extra git settings.", _schema({}), tool_identities),
|
|
490
|
+
Tool("preflight", "Versioned operation decision and state token for commit, push or GitHub PR creation. "
|
|
491
|
+
"Read-only; online=true explicitly permits account/destination reads. Ready is not authorization.",
|
|
492
|
+
_schema({"request": REQUEST_SCHEMA, "online": {"type": "boolean"}}, ("request",)), tool_preflight),
|
|
493
|
+
Tool("operation_status", "Read the durable result of an idempotency key. Never executes or retries.",
|
|
494
|
+
_schema({"idempotency_key": _string("the original operation key")}, ("idempotency_key",)),
|
|
495
|
+
tool_operation_status),
|
|
496
|
+
Tool("reconcile_operation", "Read-only inspection of recorded commit/push/PR intent versus current state. "
|
|
497
|
+
"Remote reads require online=true. Does not retry writes, change the ledger outcome or prove attribution.",
|
|
498
|
+
_schema({"idempotency_key": _string("the original operation key"), "online": {"type": "boolean"}},
|
|
499
|
+
("idempotency_key",)), tool_reconcile_operation),
|
|
500
|
+
Tool("propose_change", "Read-only exact configuration/repair proposal. Does not record approval or change "
|
|
501
|
+
"target files. Reports coverage by the operator's startup policy; otherwise ask a human to review "
|
|
502
|
+
"its JSON with gitpro change approve --write in their terminal.",
|
|
503
|
+
_schema({"request": CHANGE_REQUEST_SCHEMA}, ("request",)), tool_propose_change),
|
|
504
|
+
Tool("change_status", "Read the approval/apply/recovery status of a persistent change. Does not execute.",
|
|
505
|
+
_schema({"plan_id": _string("the complete change plan ID")}, ("plan_id",)), tool_change_status),
|
|
506
|
+
Tool("apply_change", "Apply an exact persistent change with human approval or a covering operator policy. "
|
|
507
|
+
"Provide request only to obtain a scoped grant from the server's startup policy. Host authorization "
|
|
508
|
+
"is still required; clients cannot choose policies or approve themselves.",
|
|
509
|
+
_schema({"plan_id": _string("the complete plan ID"), "request": CHANGE_REQUEST_SCHEMA}, ("plan_id",)),
|
|
510
|
+
tool_apply_change, execution=True),
|
|
511
|
+
Tool("recover_change", "Recover an interrupted approved change by restoring its recorded backups only "
|
|
512
|
+
"where current files match the planned before/after contents and modes. Never retries forward writes.",
|
|
513
|
+
_schema({"plan_id": _string("the interrupted approved plan ID")}, ("plan_id",)),
|
|
514
|
+
tool_recover_change, execution=True),
|
|
515
|
+
Tool("run_operation", "EXECUTES a reviewed commit, push or GitHub PR operation; requires host/user approval. "
|
|
516
|
+
"Never edits identity configuration. Reuse the exact request, plan_token and idempotency_key for retries. "
|
|
517
|
+
"Unknown outcomes must be inspected, not retried under a new key.",
|
|
518
|
+
_schema({"request": REQUEST_SCHEMA, "plan_token": _string("the reviewed ready preflight token"),
|
|
519
|
+
"idempotency_key": _string("stable unique operation key"), "online": {"type": "boolean"}},
|
|
520
|
+
("request", "plan_token", "idempotency_key")), tool_run_operation, execution=True),
|
|
521
|
+
)}
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _check_value(name: str, spec: dict, value: object) -> None:
|
|
525
|
+
kind = spec["type"]
|
|
526
|
+
if kind == "string":
|
|
527
|
+
good = isinstance(value, str) and "\0" not in value and len(value) >= spec.get("minLength", 0)
|
|
528
|
+
elif kind == "boolean":
|
|
529
|
+
good = isinstance(value, bool)
|
|
530
|
+
elif kind == "integer":
|
|
531
|
+
good = type(value) is int
|
|
532
|
+
elif kind == "object":
|
|
533
|
+
good = isinstance(value, dict)
|
|
534
|
+
if good:
|
|
535
|
+
validate(spec, value)
|
|
536
|
+
elif kind == "array":
|
|
537
|
+
if isinstance(value, list) and len(value) > spec.get("maxItems", len(value)):
|
|
538
|
+
raise RpcError(INVALID_PARAMS, f"'{name}': at most {spec['maxItems']} entries")
|
|
539
|
+
good = isinstance(value, list)
|
|
540
|
+
for v in value if good else ():
|
|
541
|
+
_check_value(name, spec["items"], v)
|
|
542
|
+
else:
|
|
543
|
+
good = False
|
|
544
|
+
if not good:
|
|
545
|
+
article = "an" if kind[0] in "aeiou" else "a"
|
|
546
|
+
raise RpcError(INVALID_PARAMS, f"'{name}' must be {article} {kind}"
|
|
547
|
+
+ (" (non-empty)" if kind == "string" else ""))
|
|
548
|
+
if kind == "string" and len(value) > spec.get("maxLength", len(value)):
|
|
549
|
+
raise RpcError(INVALID_PARAMS, f"'{name}' is too long")
|
|
550
|
+
if "enum" in spec and value not in spec["enum"]:
|
|
551
|
+
raise RpcError(INVALID_PARAMS, f"'{name}' must be one of {', '.join(map(str, spec['enum']))}")
|
|
552
|
+
|
|
553
|
+
|
|
554
|
+
def validate(schema: dict, args: object) -> None:
|
|
555
|
+
"""The subset of JSON Schema the tools use; RpcError -32602 when args do not fit."""
|
|
556
|
+
if not isinstance(args, dict):
|
|
557
|
+
raise RpcError(INVALID_PARAMS, "arguments must be an object")
|
|
558
|
+
for key in schema["required"]:
|
|
559
|
+
if key not in args:
|
|
560
|
+
raise RpcError(INVALID_PARAMS, f"missing argument '{key}'")
|
|
561
|
+
for key, value in args.items():
|
|
562
|
+
if key not in schema["properties"]:
|
|
563
|
+
raise RpcError(INVALID_PARAMS, f"unknown argument '{key}'")
|
|
564
|
+
_check_value(key, schema["properties"][key], value)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
# ---------------------------------------------------------------- the server
|
|
568
|
+
|
|
569
|
+
def redact(value: object) -> object:
|
|
570
|
+
"""value with a password or token in any URL userinfo replaced by *** (resolve.redact_url), in every
|
|
571
|
+
string and key, however deep: the backstop that keeps credentials in remote URLs from the model."""
|
|
572
|
+
from .resolve import redact_text
|
|
573
|
+
if isinstance(value, str):
|
|
574
|
+
return redact_text(value) if "@" in value else value
|
|
575
|
+
if isinstance(value, dict):
|
|
576
|
+
return {redact(k): redact(v) for k, v in value.items()}
|
|
577
|
+
if isinstance(value, (list, tuple)):
|
|
578
|
+
return [redact(v) for v in value]
|
|
579
|
+
return value
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _open_log(path: Path) -> IO[str]:
|
|
583
|
+
fd = os.open(str(Path(path).expanduser()), os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o600)
|
|
584
|
+
return os.fdopen(fd, "a", encoding="utf-8")
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
class Server:
|
|
588
|
+
"""One client session: the negotiated version, the output handle, the optional log."""
|
|
589
|
+
|
|
590
|
+
def __init__(self, out: IO[str], cfg_path: Optional[Path], log: Optional[IO[str]] = None,
|
|
591
|
+
allow_execution: bool = False, approval_policy: Optional[Path] = None):
|
|
592
|
+
self.out, self.cfg_path, self.log = out, cfg_path, log
|
|
593
|
+
self.allow_execution = allow_execution
|
|
594
|
+
self.approval_policy = approval_policy
|
|
595
|
+
self.version = PROTOCOL_VERSIONS[-1]
|
|
596
|
+
|
|
597
|
+
def note(self, arrow: str, text: str) -> None:
|
|
598
|
+
if not self.log:
|
|
599
|
+
return
|
|
600
|
+
try:
|
|
601
|
+
self.log.write(f"{time.strftime('%Y-%m-%dT%H:%M:%S')} {arrow} {text.rstrip()}\n")
|
|
602
|
+
self.log.flush()
|
|
603
|
+
except (OSError, ValueError) as e: # disk full, file gone: the session goes on without the log
|
|
604
|
+
self.log = None
|
|
605
|
+
print(f"⚠ gitpro mcp: --log stopped ({type(e).__name__}: {e})", file=sys.stderr)
|
|
606
|
+
|
|
607
|
+
def send(self, msg: dict) -> None:
|
|
608
|
+
text = json.dumps(msg) # ASCII escapes: the stream never depends on an encoding
|
|
609
|
+
self.note("→", text)
|
|
610
|
+
self.out.write(text + "\n")
|
|
611
|
+
self.out.flush()
|
|
612
|
+
|
|
613
|
+
def handle(self, line: str) -> Optional[dict]:
|
|
614
|
+
"""The response to one line, or None (notifications, responses sent to us)."""
|
|
615
|
+
self.note("←", line)
|
|
616
|
+
try:
|
|
617
|
+
msg = json.loads(line, object_pairs_hook=unique_members)
|
|
618
|
+
except OperationError:
|
|
619
|
+
return _error(None, PARSE_ERROR, "parse error: duplicate JSON member")
|
|
620
|
+
except (ValueError, RecursionError): # RecursionError: nesting deeper than the parser allows
|
|
621
|
+
return _error(None, PARSE_ERROR, "parse error")
|
|
622
|
+
if not isinstance(msg, dict):
|
|
623
|
+
return _error(None, INVALID_REQUEST, "invalid request: not an object")
|
|
624
|
+
method = msg.get("method")
|
|
625
|
+
if not isinstance(method, str) and ("result" in msg or "error" in msg):
|
|
626
|
+
return None # a response to a request we never send
|
|
627
|
+
is_request = "id" in msg
|
|
628
|
+
mid = msg.get("id")
|
|
629
|
+
if is_request and (mid is None or isinstance(mid, bool) or not isinstance(mid, (str, int))):
|
|
630
|
+
return _error(None, INVALID_REQUEST, "invalid request: id must be a string or an integer")
|
|
631
|
+
if msg.get("jsonrpc") != "2.0":
|
|
632
|
+
return _error(mid, INVALID_REQUEST, 'invalid request: "jsonrpc" must be "2.0"') if is_request else None
|
|
633
|
+
if not isinstance(method, str):
|
|
634
|
+
return _error(mid, INVALID_REQUEST, "invalid request: no method")
|
|
635
|
+
if not is_request:
|
|
636
|
+
return None
|
|
637
|
+
try:
|
|
638
|
+
return {"jsonrpc": "2.0", "id": mid, "result": self.dispatch(method, msg.get("params"))}
|
|
639
|
+
except RpcError as e:
|
|
640
|
+
return _error(mid, e.code, e.message)
|
|
641
|
+
|
|
642
|
+
def dispatch(self, method: str, params: object) -> dict:
|
|
643
|
+
params = {} if params is None else params
|
|
644
|
+
if not isinstance(params, dict):
|
|
645
|
+
raise RpcError(INVALID_PARAMS, "params must be an object")
|
|
646
|
+
if method == "initialize":
|
|
647
|
+
asked = params.get("protocolVersion")
|
|
648
|
+
self.version = asked if asked in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[-1]
|
|
649
|
+
return {"protocolVersion": self.version, "capabilities": {"tools": {"listChanged": False}},
|
|
650
|
+
"serverInfo": {"name": "gitpro", "version": __version__},
|
|
651
|
+
"instructions": (INSTRUCTIONS if not self.allow_execution else
|
|
652
|
+
"Execution tools are enabled. Obtain host/user approval before each write. "
|
|
653
|
+
"A preflight token checks state, not permission. apply_change and "
|
|
654
|
+
"recover_change additionally require recorded approval. apply_change can "
|
|
655
|
+
"use a covering operator startup policy, never a client-selected policy. "
|
|
656
|
+
"There is no approval tool. Other tools remain read-only.")}
|
|
657
|
+
if method == "ping":
|
|
658
|
+
return {}
|
|
659
|
+
if method == "tools/list":
|
|
660
|
+
return {"tools": [t.listing() for t in TOOLS.values()
|
|
661
|
+
if t.available() and (not t.execution or self.allow_execution)]}
|
|
662
|
+
if method == "tools/call":
|
|
663
|
+
return self.call(params)
|
|
664
|
+
empty = {"resources/list": "resources", "resources/templates/list": "resourceTemplates",
|
|
665
|
+
"prompts/list": "prompts"}
|
|
666
|
+
if method in empty:
|
|
667
|
+
return {empty[method]: []}
|
|
668
|
+
raise RpcError(NO_METHOD, f"method not found: {method}")
|
|
669
|
+
|
|
670
|
+
def call(self, params: dict) -> dict:
|
|
671
|
+
name = params.get("name")
|
|
672
|
+
tool = TOOLS.get(name) if isinstance(name, str) else None
|
|
673
|
+
if tool is None or not tool.available() or (tool.execution and not self.allow_execution):
|
|
674
|
+
raise RpcError(INVALID_PARAMS, f"unknown tool: {name}")
|
|
675
|
+
args = params.get("arguments")
|
|
676
|
+
args = {} if args is None else args
|
|
677
|
+
validate(tool.schema, args)
|
|
678
|
+
print(f"gitpro mcp: tools/call {tool.name}", file=sys.stderr) # the tool only, never its arguments
|
|
679
|
+
try:
|
|
680
|
+
with contextlib.redirect_stdout(sys.stderr):
|
|
681
|
+
obj = tool.fn(args, Ctx(self.cfg_path, self.allow_execution, self.approval_policy))
|
|
682
|
+
return self.result(obj)
|
|
683
|
+
except RpcError:
|
|
684
|
+
raise
|
|
685
|
+
except (SystemExit, KeyboardInterrupt) as e: # one request never ends the session
|
|
686
|
+
code = getattr(e, "code", None)
|
|
687
|
+
return self.failed(f"internal error: the tool stopped ({type(e).__name__}"
|
|
688
|
+
+ (f" {code}" if code not in (None, 0) else "") + ")")
|
|
689
|
+
except (ToolError, ConfigError, OSError, UnicodeError) as e:
|
|
690
|
+
return self.failed(str(e))
|
|
691
|
+
except subprocess.CalledProcessError as e:
|
|
692
|
+
cmd = e.cmd if isinstance(e.cmd, str) else shlex.join(str(c) for c in e.cmd)
|
|
693
|
+
return self.failed(f"{cmd} failed ({e.returncode})")
|
|
694
|
+
except Exception as e: # noqa: BLE001 - a tool must never kill the server
|
|
695
|
+
tb = traceback.format_exc()
|
|
696
|
+
sys.stderr.write(tb)
|
|
697
|
+
self.note("!", tb)
|
|
698
|
+
return self.failed(f"internal error: {type(e).__name__}")
|
|
699
|
+
|
|
700
|
+
def result(self, obj: dict) -> dict:
|
|
701
|
+
"""The tools/call result; TypeError/ValueError when obj is not plain JSON (call() makes that isError)."""
|
|
702
|
+
if not isinstance(obj, dict):
|
|
703
|
+
raise TypeError(f"tool returned {type(obj).__name__}, not an object")
|
|
704
|
+
obj = redact(obj)
|
|
705
|
+
out = {"content": [{"type": "text", "text": json.dumps(obj, ensure_ascii=False, allow_nan=False)}],
|
|
706
|
+
"isError": False}
|
|
707
|
+
if self.version >= STRUCTURED_SINCE:
|
|
708
|
+
out["structuredContent"] = obj
|
|
709
|
+
return out
|
|
710
|
+
|
|
711
|
+
@staticmethod
|
|
712
|
+
def failed(message: str) -> dict:
|
|
713
|
+
return {"content": [{"type": "text", "text": f"✗ {message}"}], "isError": True}
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _error(mid: object, code: int, message: str) -> dict:
|
|
717
|
+
return {"jsonrpc": "2.0", "id": mid, "error": {"code": code, "message": message}}
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def serve(stdin: IO[str], stdout: IO[str], cfg_path: Optional[Path] = None, log: Optional[Path] = None,
|
|
721
|
+
allow_execution: bool = False, approval_policy: Optional[Path] = None) -> int:
|
|
722
|
+
"""Answer JSON-RPC lines from stdin on stdout until EOF; returns 0."""
|
|
723
|
+
logf = _open_log(log) if log else None
|
|
724
|
+
old = os.environ.get("GIT_TERMINAL_PROMPT")
|
|
725
|
+
os.environ["GIT_TERMINAL_PROMPT"] = "0"
|
|
726
|
+
server = Server(stdout, cfg_path, logf, allow_execution, approval_policy)
|
|
727
|
+
try:
|
|
728
|
+
while True:
|
|
729
|
+
line = stdin.readline()
|
|
730
|
+
if not line:
|
|
731
|
+
return 0
|
|
732
|
+
if line.strip():
|
|
733
|
+
try:
|
|
734
|
+
reply = server.handle(line)
|
|
735
|
+
except Exception as e: # noqa: BLE001 - one bad request never ends the session
|
|
736
|
+
sys.stderr.write(traceback.format_exc())
|
|
737
|
+
reply = _error(None, INTERNAL_ERROR, f"internal error: {type(e).__name__}")
|
|
738
|
+
if reply is not None:
|
|
739
|
+
server.send(reply)
|
|
740
|
+
finally:
|
|
741
|
+
if old is None:
|
|
742
|
+
os.environ.pop("GIT_TERMINAL_PROMPT", None)
|
|
743
|
+
else:
|
|
744
|
+
os.environ["GIT_TERMINAL_PROMPT"] = old
|
|
745
|
+
if logf:
|
|
746
|
+
logf.close()
|
|
747
|
+
|
|
748
|
+
|
|
749
|
+
def _private_streams() -> Tuple[IO[str], IO[str]]:
|
|
750
|
+
"""The protocol streams on private copies of fds 0 and 1. fd 0 becomes /dev/null and fd 1 a copy of
|
|
751
|
+
stderr, so no child process and no stray write can read or corrupt the stream."""
|
|
752
|
+
sys.stdout.flush()
|
|
753
|
+
try:
|
|
754
|
+
rfd, wfd = os.dup(0), os.dup(1)
|
|
755
|
+
null = os.open(os.devnull, os.O_RDONLY)
|
|
756
|
+
os.dup2(null, 0)
|
|
757
|
+
os.close(null)
|
|
758
|
+
os.dup2(2, 1)
|
|
759
|
+
except OSError:
|
|
760
|
+
return sys.stdin, sys.stdout
|
|
761
|
+
return (open(rfd, "r", encoding="utf-8", errors="replace"),
|
|
762
|
+
open(wfd, "w", encoding="utf-8", newline="\n"))
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def cmd_mcp(args: argparse.Namespace) -> int:
|
|
766
|
+
from .operation_io import termination_signals
|
|
767
|
+
stdin, stdout = _private_streams()
|
|
768
|
+
with termination_signals():
|
|
769
|
+
return serve(stdin, stdout, args.config, args.log, args.allow_execution, args.approval_policy)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
# ---------------------------------------------------------------- install / uninstall
|
|
773
|
+
|
|
774
|
+
AGENTS = ("claude", "claude-desktop", "cursor", "gemini", "opencode")
|
|
775
|
+
ENTRY = {"type": "stdio", "command": "gitpro", "args": ["mcp"]}
|
|
776
|
+
PLAIN_ENTRY = {"command": "gitpro", "args": ["mcp"]} # formats that document no "type"
|
|
777
|
+
OPENCODE_ENTRY = {"type": "local", "command": ["gitpro", "mcp"]}
|
|
778
|
+
PROJECT_FILES = {"claude": ".mcp.json", "cursor": ".cursor/mcp.json"}
|
|
779
|
+
CLAUDE_ADD = ["claude", "mcp", "add", "--scope", "user", "--transport", "stdio", "gitpro", "--", "gitpro", "mcp"]
|
|
780
|
+
CLAUDE_REMOVE = ["claude", "mcp", "remove", "--scope", "user", "gitpro"]
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def _user_target(agent: str, home: Path) -> Tuple[Path, str, dict]:
|
|
784
|
+
"""(file, servers key, entry) for an agent's user-level config."""
|
|
785
|
+
xdg = Path(os.environ.get("XDG_CONFIG_HOME") or home / ".config")
|
|
786
|
+
if agent == "claude":
|
|
787
|
+
return home / ".claude.json", "mcpServers", ENTRY
|
|
788
|
+
if agent == "claude-desktop":
|
|
789
|
+
base = home / "Library/Application Support/Claude" if sys.platform == "darwin" else xdg / "Claude"
|
|
790
|
+
return base / "claude_desktop_config.json", "mcpServers", PLAIN_ENTRY
|
|
791
|
+
if agent == "cursor":
|
|
792
|
+
return home / ".cursor/mcp.json", "mcpServers", ENTRY
|
|
793
|
+
if agent == "gemini":
|
|
794
|
+
return home / ".gemini/settings.json", "mcpServers", PLAIN_ENTRY
|
|
795
|
+
return xdg / "opencode/opencode.json", "mcp", OPENCODE_ENTRY
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _target(args: argparse.Namespace) -> Tuple[Path, str, dict]:
|
|
799
|
+
if args.scope == "user":
|
|
800
|
+
return _user_target(args.agent, Path(args.home).expanduser())
|
|
801
|
+
from . import resolve
|
|
802
|
+
rel = PROJECT_FILES.get(args.agent)
|
|
803
|
+
if rel is None:
|
|
804
|
+
raise ConfigError(f"{args.agent} has no project-level config; use --scope user")
|
|
805
|
+
if not args.write and args.yes:
|
|
806
|
+
raise ConfigError(f"{rel} is shared with your team; re-run with --write to edit it (backup + undo), "
|
|
807
|
+
"or use --scope user")
|
|
808
|
+
top = resolve.is_repo(Path.cwd())
|
|
809
|
+
if top is None:
|
|
810
|
+
raise ConfigError(f"{Path.cwd()}: not a git repository (--scope project edits the repo's {rel})")
|
|
811
|
+
return top / rel, "mcpServers", ENTRY
|
|
812
|
+
|
|
813
|
+
|
|
814
|
+
def _ours(value: object, entry: dict) -> bool:
|
|
815
|
+
"""True when value is gitpro's own server entry (extra keys such as "env" are allowed)."""
|
|
816
|
+
return isinstance(value, dict) and value.get("command") == entry["command"] and value.get("args") == entry.get("args")
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _precheck(path: Path, key: str, snippet: str) -> Optional[dict]:
|
|
820
|
+
"""The file's JSON (None when missing); ConfigError for anything gitpro must not edit."""
|
|
821
|
+
from . import jsonfile, undo
|
|
822
|
+
shown = undo.tilde(path)
|
|
823
|
+
try:
|
|
824
|
+
text = path.read_text(encoding="utf-8")
|
|
825
|
+
except FileNotFoundError:
|
|
826
|
+
return None
|
|
827
|
+
try:
|
|
828
|
+
obj = json.loads(text)
|
|
829
|
+
except ValueError:
|
|
830
|
+
if jsonfile.strip_jsonc(text)[1]:
|
|
831
|
+
raise ConfigError(f"{shown} has comments; gitpro cannot edit it without losing them"
|
|
832
|
+
f" — add this by hand: {snippet}") from None
|
|
833
|
+
raise ConfigError(f"{shown} is not valid JSON; fix it by hand (nothing written)") from None
|
|
834
|
+
if not isinstance(obj, dict):
|
|
835
|
+
raise ConfigError(f"{shown} is not a JSON object; fix it by hand (nothing written)")
|
|
836
|
+
if not isinstance(obj.get(key, {}), dict):
|
|
837
|
+
raise ConfigError(f"{shown}: {key} is not an object; fix it by hand (nothing written)")
|
|
838
|
+
return obj
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def _confirm_with(args: argparse.Namespace, question: str) -> Callable[[str], bool]:
|
|
842
|
+
"""A confirm callback that asks at most once: after a yes (e.g. to `claude mcp add`, before the
|
|
843
|
+
fallback edits the file) later calls show their diff and go ahead."""
|
|
844
|
+
said_yes = []
|
|
845
|
+
|
|
846
|
+
def confirm(diff: str) -> bool:
|
|
847
|
+
if args.yes:
|
|
848
|
+
return True
|
|
849
|
+
if not said_yes and not sys.stdin.isatty():
|
|
850
|
+
raise ConfigError("no terminal to confirm on; re-run with --yes")
|
|
851
|
+
from . import view
|
|
852
|
+
if diff:
|
|
853
|
+
print(diff, end="" if diff.endswith("\n") else "\n")
|
|
854
|
+
if said_yes:
|
|
855
|
+
return True
|
|
856
|
+
ok = view.confirm(question)
|
|
857
|
+
if ok:
|
|
858
|
+
said_yes.append(True)
|
|
859
|
+
return ok
|
|
860
|
+
return confirm
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def _foreign(path: Path, current: object) -> str:
|
|
864
|
+
cmd = current.get("command") if isinstance(current, dict) else None
|
|
865
|
+
return f"{_tilde_path(path)} already has a 'gitpro' server that is not gitpro's own" + \
|
|
866
|
+
(f" (command {json.dumps(cmd)})" if cmd is not None else "")
|
|
867
|
+
|
|
868
|
+
|
|
869
|
+
def _tilde_path(path: Path) -> str:
|
|
870
|
+
from . import undo
|
|
871
|
+
return undo.tilde(path)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _claude_cli(args: argparse.Namespace, removing: bool, confirm: Callable[[str], bool]) -> Optional[dict]:
|
|
875
|
+
"""Register/unregister through `claude mcp add|remove` (the CLI owns ~/.claude.json). The outcome, or
|
|
876
|
+
None when the caller should edit the file itself (the CLI failed)."""
|
|
877
|
+
from . import undo, view
|
|
878
|
+
argv, back = (CLAUDE_REMOVE, CLAUDE_ADD) if removing else (CLAUDE_ADD, CLAUDE_REMOVE)
|
|
879
|
+
if not confirm(f"run: {shlex.join(argv)}\n"):
|
|
880
|
+
return {"changed": False, "aborted": True}
|
|
881
|
+
command = "mcp uninstall" if removing else "mcp install"
|
|
882
|
+
script = undo.write(command, [shlex.join(back)], what=f"ran {shlex.join(argv[:3])}")
|
|
883
|
+
try:
|
|
884
|
+
r = subprocess.run(argv, stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=CLI_TIMEOUT)
|
|
885
|
+
rc: object = r.returncode
|
|
886
|
+
except (OSError, subprocess.TimeoutExpired) as e:
|
|
887
|
+
rc = "timeout" if isinstance(e, subprocess.TimeoutExpired) else type(e).__name__
|
|
888
|
+
if rc == 0:
|
|
889
|
+
return {"changed": True, "backup": None, "undo": script, "via": "claude-cli"}
|
|
890
|
+
script.unlink()
|
|
891
|
+
view.warn(f"{shlex.join(argv[:3])} failed ({rc}); editing ~/.claude.json directly")
|
|
892
|
+
return None
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def _file_edit(args, path: Path, key: str, entry: dict, removing: bool, confirm, snippet: str) -> dict:
|
|
896
|
+
from . import jsonfile, view
|
|
897
|
+
|
|
898
|
+
def mutate(obj):
|
|
899
|
+
if not isinstance(obj, dict) or not isinstance(obj.get(key, {}), dict):
|
|
900
|
+
raise ConfigError(f"{_tilde_path(path)}: {key} is not an object; fix it by hand (nothing written)")
|
|
901
|
+
servers = obj.setdefault(key, {})
|
|
902
|
+
current = servers.get("gitpro")
|
|
903
|
+
# checked again on the content edit_json re-read: the claude CLI may have written it meanwhile
|
|
904
|
+
if current is not None and not _ours(current, entry) and not getattr(args, "force", False):
|
|
905
|
+
raise ConfigError(_foreign(path, current) + ("; left alone" if removing else
|
|
906
|
+
"; re-run with --force to replace it"))
|
|
907
|
+
if removing:
|
|
908
|
+
servers.pop("gitpro", None)
|
|
909
|
+
if not servers and args.scope == "project":
|
|
910
|
+
del obj[key] # the team file keeps no empty "mcpServers": {}
|
|
911
|
+
else:
|
|
912
|
+
servers["gitpro"] = entry
|
|
913
|
+
return obj
|
|
914
|
+
if args.agent == "claude" and args.scope == "user":
|
|
915
|
+
# R-036: a running Claude Code rewrites ~/.claude.json constantly
|
|
916
|
+
view.warn("close Claude Code first: it rewrites ~/.claude.json while it runs and could undo this edit")
|
|
917
|
+
res = jsonfile.edit_json(path, mutate, command="mcp uninstall" if removing else "mcp install", jsonc=False,
|
|
918
|
+
create={}, confirm=confirm, mkdir=True, snippet=snippet)
|
|
919
|
+
return {"changed": res.changed, "aborted": not res.changed, "backup": res.backup, "undo": res.undo,
|
|
920
|
+
"via": "file"}
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def _report(args, path: Path, removing: bool, outcome: dict) -> int:
|
|
924
|
+
from . import jsonout, undo, view
|
|
925
|
+
command = "mcp uninstall" if removing else "mcp install"
|
|
926
|
+
if args.json:
|
|
927
|
+
jsonout.emit(command, {"file": str(path), "changed": outcome["changed"],
|
|
928
|
+
"backup": str(outcome["backup"]) if outcome.get("backup") else None,
|
|
929
|
+
"undo": str(outcome["undo"]) if outcome.get("undo") else None,
|
|
930
|
+
"via": outcome.get("via", "file")}, ok=not outcome.get("aborted"))
|
|
931
|
+
return 1 if outcome.get("aborted") else 0
|
|
932
|
+
if outcome.get("aborted"):
|
|
933
|
+
view.aborted()
|
|
934
|
+
return 1
|
|
935
|
+
if not outcome["changed"]:
|
|
936
|
+
view.ok(f"{'not registered' if removing else 'already registered'} in {undo.tilde(path)}, nothing to do")
|
|
937
|
+
return 0
|
|
938
|
+
how = "with claude mcp " + ("remove" if removing else "add") if outcome["via"] == "claude-cli" \
|
|
939
|
+
else f"in {undo.tilde(path)}"
|
|
940
|
+
view.ok(f"{'removed' if removing else 'registered'} the gitpro MCP server {how}")
|
|
941
|
+
restores = (f"restores the whole file from {undo.tilde(outcome['backup'])}; changes made to it later, "
|
|
942
|
+
"e.g. by Claude Code, are lost") if outcome.get("backup") else \
|
|
943
|
+
("re-registers it" if removing else "removes it")
|
|
944
|
+
print(f"↩ undo: sh {undo.tilde(outcome['undo'])} ({restores})")
|
|
945
|
+
return 0
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _edit(args: argparse.Namespace, removing: bool) -> int:
|
|
949
|
+
from .args import fail, preview_only, preview_result
|
|
950
|
+
from . import view
|
|
951
|
+
if args.json and not args.yes and not preview_only(args):
|
|
952
|
+
return fail("--write --json needs -y (there is no confirmation in JSON mode)", args)
|
|
953
|
+
path, key, entry = _target(args)
|
|
954
|
+
if args.agent == "claude" and not removing and not args.json:
|
|
955
|
+
view.step("plugin users already have the server (the gitpro plugin ships .mcp.json)")
|
|
956
|
+
snippet = json.dumps({key: {"gitpro": entry}})
|
|
957
|
+
obj = _precheck(path, key, snippet)
|
|
958
|
+
current = (obj or {}).get(key, {}).get("gitpro")
|
|
959
|
+
if removing and current is not None and not _ours(current, entry):
|
|
960
|
+
view.warn(f"{_foreign(path, current)}; left alone")
|
|
961
|
+
return 1
|
|
962
|
+
if not removing and current is not None and not _ours(current, entry) and not getattr(args, "force", False):
|
|
963
|
+
return fail(f"{_foreign(path, current)}; re-run with --force to replace it", args)
|
|
964
|
+
if (current is None) if removing else _ours(current, entry):
|
|
965
|
+
return _report(args, path, removing, {"changed": False, "via": "file"})
|
|
966
|
+
if preview_only(args):
|
|
967
|
+
if not args.json:
|
|
968
|
+
print(f"Would {'remove' if removing else 'register'} gitpro in {path}:\n{snippet}")
|
|
969
|
+
return preview_result(args, "mcp uninstall" if removing else "mcp install",
|
|
970
|
+
{"file": str(path), "changed": False, "entry": entry,
|
|
971
|
+
"action": "remove" if removing else "install", "backup": None, "undo": None})
|
|
972
|
+
confirm = _confirm_with(args, "Remove it?" if removing else "Register it?")
|
|
973
|
+
outcome = None
|
|
974
|
+
if args.agent == "claude" and args.scope == "user" and shutil.which("claude"):
|
|
975
|
+
outcome = _claude_cli(args, removing, confirm)
|
|
976
|
+
if outcome is None:
|
|
977
|
+
outcome = _file_edit(args, path, key, entry, removing, confirm, snippet)
|
|
978
|
+
return _report(args, path, removing, outcome)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def cmd_install(args: argparse.Namespace) -> int:
|
|
982
|
+
return _edit(args, removing=False)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def cmd_uninstall(args: argparse.Namespace) -> int:
|
|
986
|
+
return _edit(args, removing=True)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
# ---------------------------------------------------------------- doctor
|
|
990
|
+
|
|
991
|
+
def _shown(path: Path, home: Path) -> str:
|
|
992
|
+
p, h = str(path), str(home)
|
|
993
|
+
return "~" + p[len(h):] if p == h or p.startswith(h + "/") else p
|
|
994
|
+
|
|
995
|
+
|
|
996
|
+
def status_data(home: Path) -> dict:
|
|
997
|
+
"""Which agents' user configs register the gitpro server; reads files only (never runs `claude`)."""
|
|
998
|
+
from . import jsonfile
|
|
999
|
+
home = Path(home)
|
|
1000
|
+
found, problems = [], []
|
|
1001
|
+
for agent in AGENTS:
|
|
1002
|
+
path, key, entry = _user_target(agent, home)
|
|
1003
|
+
try:
|
|
1004
|
+
text = path.read_text(encoding="utf-8")
|
|
1005
|
+
except FileNotFoundError:
|
|
1006
|
+
continue
|
|
1007
|
+
except OSError as e:
|
|
1008
|
+
problems.append({"message": f"cannot read {_shown(path, home)} ({e.strerror})", "fix": "",
|
|
1009
|
+
"level": "warning"})
|
|
1010
|
+
continue
|
|
1011
|
+
try:
|
|
1012
|
+
obj = json.loads(jsonfile.strip_jsonc(text)[0])
|
|
1013
|
+
except ValueError:
|
|
1014
|
+
problems.append({"message": f"{_shown(path, home)} is not valid JSON; {agent} cannot load its MCP "
|
|
1015
|
+
"servers", "fix": f"fix {_shown(path, home)} by hand", "level": "warning"})
|
|
1016
|
+
continue
|
|
1017
|
+
servers = obj.get(key) if isinstance(obj, dict) else None
|
|
1018
|
+
if isinstance(servers, dict) and _ours(servers.get("gitpro"), entry):
|
|
1019
|
+
found.append({"agent": agent, "file": str(path)})
|
|
1020
|
+
summary = ("mcp registered: " + ", ".join(f"{f['agent']} ({_shown(Path(f['file']), home)})" for f in found)
|
|
1021
|
+
if found else "mcp not registered (optional: gitpro mcp install)")
|
|
1022
|
+
return {"status": "warning" if problems else "ok", "summary": summary, "registered": found,
|
|
1023
|
+
"problems": problems, "details": [f"mcp: {f['agent']} → {_shown(Path(f['file']), home)}" for f in found]}
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def doctor_section(ctx) -> dict:
|
|
1027
|
+
data = status_data(ctx.home)
|
|
1028
|
+
return {k: data[k] for k in ("status", "summary", "problems", "details")}
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
# ---------------------------------------------------------------- CLI
|
|
1032
|
+
|
|
1033
|
+
def register(sub, common, confirm) -> None:
|
|
1034
|
+
from .args import capability
|
|
1035
|
+
p = sub.add_parser("mcp", parents=[common],
|
|
1036
|
+
help="run the read-only MCP server for AI assistants (stdio); install registers it")
|
|
1037
|
+
p.add_argument("--log", type=Path, metavar="FILE", help="append every request and response to FILE (0600)")
|
|
1038
|
+
p.add_argument("--allow-execution", action="store_true",
|
|
1039
|
+
help="expose operation and approved-change execution; the host must authorize each write")
|
|
1040
|
+
p.add_argument("--approval-policy", type=Path, help="operator-selected scoped grants; never a client argument")
|
|
1041
|
+
p.set_defaults(func=cmd_mcp)
|
|
1042
|
+
actions = p.add_subparsers(dest="mcp_action", metavar="ACTION")
|
|
1043
|
+
for name, func, text in (("install", cmd_install, "register the server with an AI assistant (backup + undo)"),
|
|
1044
|
+
("uninstall", cmd_uninstall, "remove the registration (backup + undo)")):
|
|
1045
|
+
a = actions.add_parser(name, parents=[confirm], help=text)
|
|
1046
|
+
a.add_argument("--agent", choices=AGENTS, default="claude", help="which assistant (default: claude)")
|
|
1047
|
+
a.add_argument("--scope", choices=("user", "project"), default="user",
|
|
1048
|
+
help="user config (default) or the repo's tracked file (claude, cursor; needs --write)")
|
|
1049
|
+
a.add_argument("--json", action="store_true", help="machine-readable output (needs -y)")
|
|
1050
|
+
if name == "install":
|
|
1051
|
+
a.add_argument("--force", action="store_true",
|
|
1052
|
+
help="replace a 'gitpro' server entry that is not gitpro's own")
|
|
1053
|
+
a.add_argument("--home", type=Path, default=Path.home(), help=argparse.SUPPRESS)
|
|
1054
|
+
a.set_defaults(func=func, command=f"mcp {name}")
|
|
1055
|
+
capability("mcp", "mcp.install")
|