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/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")