qaas-python 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. qaas/adapters/__init__.py +19 -0
  2. qaas/adapters/tracker.py +1350 -0
  3. qaas/adapters/vcs.py +494 -0
  4. qaas/cli.py +1564 -0
  5. qaas/conductor.py +527 -0
  6. qaas/config.py +407 -0
  7. qaas/defaults/config/agents/arbiter.yaml +19 -0
  8. qaas/defaults/config/agents/cartographer.yaml +20 -0
  9. qaas/defaults/config/agents/clerk.yaml +21 -0
  10. qaas/defaults/config/agents/conduit.yaml +19 -0
  11. qaas/defaults/config/agents/forge.yaml +22 -0
  12. qaas/defaults/config/agents/mender.yaml +56 -0
  13. qaas/defaults/config/agents/proof.yaml +21 -0
  14. qaas/defaults/config/agents/surface.yaml +16 -0
  15. qaas/defaults/config/system.yaml +69 -0
  16. qaas/discover.py +227 -0
  17. qaas/envelope.py +290 -0
  18. qaas/guardrails.py +431 -0
  19. qaas/mcp/__init__.py +0 -0
  20. qaas/mcp/context.py +70 -0
  21. qaas/mcp/contract_diff.py +937 -0
  22. qaas/mcp/defect_memory.py +495 -0
  23. qaas/mcp/env_control.py +905 -0
  24. qaas/mcp/envelope_server.py +463 -0
  25. qaas/mcp/test_runner.py +773 -0
  26. qaas/mcp/tracker.py +412 -0
  27. qaas/mcp/vcs.py +506 -0
  28. qaas/paths.py +317 -0
  29. qaas/plugin/.claude-plugin/plugin.json +9 -0
  30. qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
  31. qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
  32. qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
  33. qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
  34. qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
  35. qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
  36. qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
  37. qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
  38. qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
  39. qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
  40. qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
  41. qaas/plugin/skills/flake-detection/SKILL.md +39 -0
  42. qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
  43. qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
  44. qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
  45. qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
  46. qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
  47. qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
  48. qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
  49. qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
  50. qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
  51. qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
  52. qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
  53. qaas/plugin/skills/routing-rules/SKILL.md +34 -0
  54. qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
  55. qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
  56. qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
  57. qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
  58. qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
  59. qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
  60. qaas/prompts/ARBITER.md +53 -0
  61. qaas/prompts/CARTOGRAPHER.md +46 -0
  62. qaas/prompts/CLERK.md +45 -0
  63. qaas/prompts/CONDUIT.md +44 -0
  64. qaas/prompts/FORGE.md +43 -0
  65. qaas/prompts/MENDER.md +55 -0
  66. qaas/prompts/PROOF.md +41 -0
  67. qaas/prompts/SURFACE.md +46 -0
  68. qaas/prompts/_shared.md +45 -0
  69. qaas/registry.py +465 -0
  70. qaas/runner.py +192 -0
  71. qaas/scorecard.py +425 -0
  72. qaas/sdk_compat.py +52 -0
  73. qaas/store.py +290 -0
  74. qaas/target.py +261 -0
  75. qaas/tasks.py +361 -0
  76. qaas/trace.py +270 -0
  77. qaas_python-0.1.0.dist-info/METADATA +388 -0
  78. qaas_python-0.1.0.dist-info/RECORD +81 -0
  79. qaas_python-0.1.0.dist-info/WHEEL +4 -0
  80. qaas_python-0.1.0.dist-info/entry_points.txt +2 -0
  81. qaas_python-0.1.0.dist-info/licenses/LICENSE +21 -0
qaas/mcp/vcs.py ADDED
@@ -0,0 +1,506 @@
1
+ """The `vcs` MCP server — the §8.1 write matrix, enforced where the write happens.
2
+
3
+ FORGE needs a branch and a committed failing test; every other Phase 1 agent
4
+ needs none of that. The difference is one line of YAML (`policy.branch_patterns`,
5
+ `policy.write_paths`) and this module is what makes that line true. An agent
6
+ cannot argue its way past a refusal here the way it can past a prompt, and every
7
+ refusal lands in the ledger as a `denial` so the audit trail in §8 is real.
8
+
9
+ Three rules the tools below exist to keep:
10
+
11
+ * `main` and `master` are refused for everyone, always, even if a policy were
12
+ misconfigured to name them. Merging is a human act (§8.4).
13
+ * Force-push is not a tool. There is nothing to refuse because there is
14
+ nothing to call.
15
+ * A write path is checked after resolution, not before, so `..`, an absolute
16
+ path and a symlink out of the sandbox all fail the same check.
17
+ * Pushing and opening a PR ride one permission (`policy.may_open_pr`), because
18
+ publishing a branch to a shared remote exposes the same work the PR would.
19
+ There is no merge tool: §8.4 makes merging a human act.
20
+
21
+ This duplicates part of `qaas.guardrails`, which gates the built-in Write/Edit/
22
+ Bash tools. That is intentional: the two see different call paths, and the
23
+ guardrail never sees an MCP tool's arguments.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import fnmatch
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from claude_agent_sdk import create_sdk_mcp_server, tool
33
+
34
+ from qaas.adapters.vcs import VcsAdapter, VcsError, build_vcs
35
+ from qaas.mcp.context import ToolContext, err, ok
36
+
37
+ # Refused for every agent regardless of policy. A policy that names one of these
38
+ # is a misconfiguration, and this is the layer that survives it.
39
+ PROTECTED_BRANCHES = frozenset({"main", "master", "trunk", "develop", "release"})
40
+
41
+ # Cap on a single agent-authored file. A repro case that needs more than this is
42
+ # not minimal, which is FORGE's actual job (§4.11).
43
+ MAX_WRITE_BYTES = 512_000
44
+
45
+
46
+ def _deny(ctx: ToolContext, tool_name: str, reason: str) -> dict[str, Any]:
47
+ """Refuse, log, and hand the agent the reason it needs to adapt."""
48
+ ctx.store.log("denial", agent=ctx.agent.name, tool=tool_name, reason=reason)
49
+ return err(reason)
50
+
51
+
52
+ def _branch_refusal(ctx: ToolContext, branch: str) -> str | None:
53
+ """Why `branch` is off limits for this agent, or None if it is allowed."""
54
+ patterns = ctx.agent.policy.branch_patterns
55
+ if not patterns:
56
+ return (
57
+ f"{ctx.agent.name} has no branch patterns in its policy and may not "
58
+ "create branches or commit. Report what you found instead "
59
+ "(§2: the finder never fixes)."
60
+ )
61
+ if branch.lower() in PROTECTED_BRANCHES:
62
+ return (
63
+ f"'{branch}' is a protected branch. No agent writes to it, ever (§8.1). "
64
+ f"Work on one of {', '.join(patterns)}."
65
+ )
66
+ if not any(fnmatch.fnmatch(branch, pattern) for pattern in patterns):
67
+ return (
68
+ f"branch '{branch}' is outside {ctx.agent.name}'s patterns "
69
+ f"({', '.join(patterns)}). Create a branch that matches one of them first."
70
+ )
71
+ return None
72
+
73
+
74
+ def _publish_refusal(ctx: ToolContext, branch: str) -> str | None:
75
+ """Why this agent may not publish `branch`, or None if it may.
76
+
77
+ Push and open_pr share one gate. Putting a branch on the shared remote is
78
+ the same act of exposure as opening the pull request that follows it, so
79
+ §8.1's "pull requests: open only, and only for the remediation agent" has to
80
+ cover both or it covers neither.
81
+ """
82
+ if not ctx.agent.policy.may_open_pr:
83
+ return (
84
+ f"{ctx.agent.name}'s policy does not grant may_open_pr, so it may not "
85
+ "push branches or open pull requests (§8.1: pull requests are the "
86
+ "remediation agent's, open only). Commit locally and report instead."
87
+ )
88
+ return _branch_refusal(ctx, branch)
89
+
90
+
91
+ def _remote_refusal(adapter: VcsAdapter, capability: str) -> str | None:
92
+ """The local backend has no remote; say so rather than raise AttributeError."""
93
+ if not hasattr(adapter, capability):
94
+ return (
95
+ f"the configured vcs backend ({type(adapter).__name__}) has no remote, so "
96
+ f"'{capability}' does not exist. Set `vcs: github` in config/system.yaml "
97
+ "to work against a GitHub repository."
98
+ )
99
+ return None
100
+
101
+
102
+ def _path_refusal(ctx: ToolContext, raw: str) -> tuple[Path | None, str | None]:
103
+ """Resolve a repo-relative path and say why it is off limits, if it is.
104
+
105
+ Resolution happens before the containment check so `..`, an absolute path
106
+ and a symlink pointing out of the sandbox are all caught by the same test.
107
+ """
108
+ policy = ctx.agent.policy
109
+ if not policy.write_paths:
110
+ return None, (
111
+ f"{ctx.agent.name} is read-only: its policy declares no write paths. "
112
+ "Report what you found; fixing is another agent's job (§2)."
113
+ )
114
+
115
+ root = ctx.target_root.resolve()
116
+ candidate = Path(raw)
117
+ resolved = (candidate if candidate.is_absolute() else root / candidate).resolve()
118
+
119
+ if resolved != root and not resolved.is_relative_to(root):
120
+ return None, (
121
+ f"write refused: '{raw}' resolves to {resolved}, outside the repository "
122
+ f"({root}). Paths must stay inside the checkout."
123
+ )
124
+
125
+ relative = resolved.relative_to(root).as_posix()
126
+ for protected in policy.protected_paths:
127
+ if fnmatch.fnmatch(relative, protected) or relative.startswith(protected.rstrip("/") + "/"):
128
+ return None, (
129
+ f"write refused: '{relative}' is protected. It defines what a fix must "
130
+ "achieve and may not be edited (§10, symptom fixes)."
131
+ )
132
+
133
+ for allowed in policy.write_paths:
134
+ if any(ch in allowed for ch in "*?["):
135
+ if fnmatch.fnmatch(relative, allowed):
136
+ return resolved, None
137
+ continue
138
+ sandbox = (root / allowed).resolve()
139
+ if resolved == sandbox or resolved.is_relative_to(sandbox):
140
+ return resolved, None
141
+
142
+ return None, (
143
+ f"write refused: '{relative}' is outside {ctx.agent.name}'s sandbox "
144
+ f"({', '.join(policy.write_paths)}). Write your repro there instead."
145
+ )
146
+
147
+
148
+ def build_tools(ctx: ToolContext) -> list:
149
+ """The vcs tools, bound to one agent's run context.
150
+
151
+ Split from `build` so tests can call the handlers directly without standing
152
+ up an MCP transport.
153
+ """
154
+ adapter: dict[str, VcsAdapter] = {}
155
+
156
+ def vcs() -> VcsAdapter:
157
+ """Built on first use: the GitHub backend raises on construction, and a
158
+ read-only agent should not eat that error just for loading the server."""
159
+ if "adapter" not in adapter:
160
+ adapter["adapter"] = build_vcs(ctx.config.vcs, ctx.target_root)
161
+ return adapter["adapter"]
162
+
163
+ @tool(
164
+ "current_branch",
165
+ "Name of the branch currently checked out. Check this before writing: "
166
+ "writes are refused on any branch outside your policy.",
167
+ {"type": "object", "properties": {}},
168
+ )
169
+ async def current_branch(args: dict[str, Any]) -> dict[str, Any]:
170
+ try:
171
+ branch = vcs().current_branch()
172
+ except (VcsError, NotImplementedError, ValueError) as exc:
173
+ return err(str(exc))
174
+ allowed = _branch_refusal(ctx, branch) is None
175
+ note = "" if allowed else " You may not write on it."
176
+ return ok(f"On branch '{branch}'.{note}", branch=branch, writable=allowed)
177
+
178
+ @tool(
179
+ "create_branch",
180
+ "Create and switch to a branch. It must match your policy's branch patterns "
181
+ "(FORGE: qa/repro/*). main and master are refused for every agent.",
182
+ {
183
+ "type": "object",
184
+ "required": ["name"],
185
+ "properties": {
186
+ "name": {"type": "string", "description": "e.g. 'qa/repro/PROJ-1284-order-500'"},
187
+ "from_ref": {"type": "string", "description": "Base ref. Defaults to the current HEAD."},
188
+ },
189
+ },
190
+ )
191
+ async def create_branch(args: dict[str, Any]) -> dict[str, Any]:
192
+ name = str(args["name"]).strip()
193
+ if not name:
194
+ return err("name is required.")
195
+ refusal = _branch_refusal(ctx, name)
196
+ if refusal:
197
+ return _deny(ctx, "create_branch", refusal)
198
+ try:
199
+ branch = vcs().create_branch(name, args.get("from_ref"))
200
+ except (VcsError, NotImplementedError, ValueError) as exc:
201
+ return err(str(exc))
202
+ ctx.store.log("vcs", agent=ctx.agent.name, action="create_branch", branch=branch)
203
+ return ok(f"Created and switched to '{branch}'.", branch=branch)
204
+
205
+ @tool(
206
+ "write_file",
207
+ "Write one file inside your sandbox. The path is resolved first, so '..', "
208
+ "absolute paths and symlinks that leave the sandbox are all refused.",
209
+ {
210
+ "type": "object",
211
+ "required": ["path", "content"],
212
+ "properties": {
213
+ "path": {"type": "string", "description": "Repo-relative, e.g. 'qa/repro/test_order_500.py'"},
214
+ "content": {"type": "string"},
215
+ },
216
+ },
217
+ )
218
+ async def write_file(args: dict[str, Any]) -> dict[str, Any]:
219
+ raw = str(args["path"])
220
+ content = args.get("content")
221
+ if not isinstance(content, str):
222
+ return err("content must be a string.")
223
+ if len(content.encode()) > MAX_WRITE_BYTES:
224
+ return err(
225
+ f"content is {len(content.encode())} bytes; the cap is {MAX_WRITE_BYTES}. "
226
+ "A minimal repro should be far smaller than this."
227
+ )
228
+
229
+ resolved, refusal = _path_refusal(ctx, raw)
230
+ if refusal:
231
+ return _deny(ctx, "write_file", refusal)
232
+
233
+ try:
234
+ branch = vcs().current_branch()
235
+ except (VcsError, NotImplementedError, ValueError) as exc:
236
+ return err(str(exc))
237
+ branch_refusal = _branch_refusal(ctx, branch)
238
+ if branch_refusal:
239
+ return _deny(
240
+ ctx,
241
+ "write_file",
242
+ f"refusing to write while on '{branch}': {branch_refusal}",
243
+ )
244
+
245
+ assert resolved is not None # _path_refusal returns one or the other
246
+ relative = resolved.relative_to(ctx.target_root.resolve()).as_posix()
247
+ try:
248
+ vcs().write_files({relative: content})
249
+ except OSError as exc:
250
+ return err(f"could not write {relative}: {exc}")
251
+ ctx.store.log("vcs", agent=ctx.agent.name, action="write_file", path=relative, branch=branch)
252
+ return ok(f"Wrote {relative} ({len(content)} chars) on {branch}.", path=relative, branch=branch)
253
+
254
+ @tool(
255
+ "commit",
256
+ "Commit your sandbox changes on the current branch. Only files inside your "
257
+ "write paths are staged; committing on a branch outside your policy is refused.",
258
+ {
259
+ "type": "object",
260
+ "required": ["message"],
261
+ "properties": {
262
+ "message": {"type": "string", "description": "Why this commit exists, not what it changes."},
263
+ "paths": {
264
+ "type": "array",
265
+ "items": {"type": "string"},
266
+ "description": "Repo-relative paths to stage. Defaults to your whole sandbox.",
267
+ },
268
+ },
269
+ },
270
+ )
271
+ async def commit(args: dict[str, Any]) -> dict[str, Any]:
272
+ message = str(args["message"]).strip()
273
+ if not message:
274
+ return err("message is required.")
275
+ if not ctx.agent.policy.write_paths:
276
+ return _deny(
277
+ ctx,
278
+ "commit",
279
+ f"{ctx.agent.name} is read-only: its policy declares no write paths.",
280
+ )
281
+
282
+ try:
283
+ branch = vcs().current_branch()
284
+ except (VcsError, NotImplementedError, ValueError) as exc:
285
+ return err(str(exc))
286
+ refusal = _branch_refusal(ctx, branch)
287
+ if refusal:
288
+ return _deny(ctx, "commit", f"refusing to commit on '{branch}': {refusal}")
289
+
290
+ requested = args.get("paths") or ctx.agent.policy.write_paths
291
+ staged: list[str] = []
292
+ for raw in requested:
293
+ resolved, path_refusal = _path_refusal(ctx, raw)
294
+ if path_refusal:
295
+ return _deny(ctx, "commit", path_refusal)
296
+ assert resolved is not None
297
+ staged.append(resolved.relative_to(ctx.target_root.resolve()).as_posix())
298
+
299
+ try:
300
+ sha = vcs().commit(message, staged)
301
+ except (VcsError, NotImplementedError, ValueError) as exc:
302
+ return err(str(exc))
303
+ ctx.store.log("vcs", agent=ctx.agent.name, action="commit", branch=branch, sha=sha)
304
+ return ok(f"Committed {sha[:10]} on {branch}.", sha=sha, branch=branch, paths=staged)
305
+
306
+ @tool(
307
+ "diff",
308
+ "Unified diff of the working tree, optionally against a ref. Read-only.",
309
+ {
310
+ "type": "object",
311
+ "properties": {
312
+ "ref": {"type": "string", "description": "Compare against this ref, e.g. 'main' or a sha."},
313
+ "paths": {"type": "array", "items": {"type": "string"}, "description": "Limit to these paths."},
314
+ },
315
+ },
316
+ )
317
+ async def diff(args: dict[str, Any]) -> dict[str, Any]:
318
+ try:
319
+ patch = vcs().diff(args.get("ref"), args.get("paths"))
320
+ except (VcsError, NotImplementedError, ValueError) as exc:
321
+ return err(str(exc))
322
+ if not patch.strip():
323
+ return ok("No changes.", empty=True)
324
+ truncated = patch[:60_000]
325
+ return ok(truncated, truncated=len(patch) > len(truncated), lines=patch.count("\n"))
326
+
327
+ @tool(
328
+ "list_branches",
329
+ "Local branch names. Read-only.",
330
+ {"type": "object", "properties": {}},
331
+ )
332
+ async def list_branches(args: dict[str, Any]) -> dict[str, Any]:
333
+ try:
334
+ branches = vcs().list_branches()
335
+ except (VcsError, NotImplementedError, ValueError) as exc:
336
+ return err(str(exc))
337
+ return ok(", ".join(branches) or "(no branches yet)", branches=branches)
338
+
339
+ @tool(
340
+ "push",
341
+ "Publish a branch to the remote. Requires may_open_pr in your policy; main, "
342
+ "master, trunk, develop and release* are refused as the branch to push. "
343
+ "There is no force-push.",
344
+ {
345
+ "type": "object",
346
+ "properties": {
347
+ "branch": {"type": "string", "description": "Defaults to the branch you are on."},
348
+ },
349
+ },
350
+ )
351
+ async def push(args: dict[str, Any]) -> dict[str, Any]:
352
+ try:
353
+ adapter = vcs()
354
+ branch = str(args.get("branch") or "").strip() or adapter.current_branch()
355
+ except (VcsError, NotImplementedError, ValueError) as exc:
356
+ return err(str(exc))
357
+
358
+ refusal = _publish_refusal(ctx, branch)
359
+ if refusal:
360
+ return _deny(ctx, "push", refusal)
361
+ unsupported = _remote_refusal(adapter, "push")
362
+ if unsupported:
363
+ return err(unsupported)
364
+
365
+ try:
366
+ pushed = adapter.push(branch)
367
+ except (VcsError, NotImplementedError, ValueError) as exc:
368
+ return err(str(exc))
369
+ ctx.store.log("vcs", agent=ctx.agent.name, action="push", branch=pushed)
370
+ return ok(f"Pushed '{pushed}' to the remote.", branch=pushed)
371
+
372
+ @tool(
373
+ "open_pr",
374
+ "Open a pull request from a branch, as a draft. Requires may_open_pr in your "
375
+ "policy. The body always gains a line saying an automated QA agent opened it "
376
+ "and for which ticket. Merging is a human decision; there is no merge tool.",
377
+ {
378
+ "type": "object",
379
+ "required": ["title", "body", "ticket"],
380
+ "properties": {
381
+ "title": {"type": "string", "description": "What the change does, in one line."},
382
+ "body": {"type": "string", "description": "Why the change is correct, and how it was verified."},
383
+ "ticket": {"type": "string", "description": "The ticket this answers, e.g. 'PROJ-1284'."},
384
+ "branch": {"type": "string", "description": "Head branch. Defaults to the branch you are on."},
385
+ "base": {"type": "string", "description": "Base branch. Defaults to the repository's default."},
386
+ "draft": {
387
+ "type": "boolean",
388
+ "description": "Defaults to true. Leave it true unless a human asked for review.",
389
+ },
390
+ },
391
+ },
392
+ )
393
+ async def open_pr(args: dict[str, Any]) -> dict[str, Any]:
394
+ title = str(args.get("title") or "").strip()
395
+ body = str(args.get("body") or "").strip()
396
+ ticket = str(args.get("ticket") or "").strip()
397
+ if not title or not body or not ticket:
398
+ return err("title, body and ticket are all required.")
399
+
400
+ try:
401
+ adapter = vcs()
402
+ branch = str(args.get("branch") or "").strip() or adapter.current_branch()
403
+ except (VcsError, NotImplementedError, ValueError) as exc:
404
+ return err(str(exc))
405
+
406
+ refusal = _publish_refusal(ctx, branch)
407
+ if refusal:
408
+ return _deny(ctx, "open_pr", refusal)
409
+ unsupported = _remote_refusal(adapter, "open_pr")
410
+ if unsupported:
411
+ return err(unsupported)
412
+
413
+ draft = args.get("draft")
414
+ try:
415
+ pr = adapter.open_pr(
416
+ branch,
417
+ title,
418
+ body,
419
+ args.get("base"),
420
+ draft=True if draft is None else bool(draft),
421
+ ticket=ticket,
422
+ )
423
+ except (VcsError, NotImplementedError, ValueError) as exc:
424
+ return err(str(exc))
425
+ ctx.store.log(
426
+ "vcs",
427
+ agent=ctx.agent.name,
428
+ action="open_pr",
429
+ branch=branch,
430
+ ticket=ticket,
431
+ number=pr.get("number"),
432
+ url=pr.get("url"),
433
+ )
434
+ state = "draft PR" if pr.get("draft") else "PR"
435
+ return ok(f"Opened {state} #{pr.get('number')} for {ticket}: {pr.get('url')}", **pr)
436
+
437
+ @tool(
438
+ "pr_diff",
439
+ "Unified diff of an existing pull request. Read-only.",
440
+ {
441
+ "type": "object",
442
+ "required": ["number"],
443
+ "properties": {"number": {"type": "integer", "description": "The PR number."}},
444
+ },
445
+ )
446
+ async def pr_diff(args: dict[str, Any]) -> dict[str, Any]:
447
+ try:
448
+ adapter = vcs()
449
+ except (VcsError, NotImplementedError, ValueError) as exc:
450
+ return err(str(exc))
451
+ unsupported = _remote_refusal(adapter, "pr_diff")
452
+ if unsupported:
453
+ return err(unsupported)
454
+ try:
455
+ patch = adapter.pr_diff(args["number"])
456
+ except (VcsError, NotImplementedError, ValueError, KeyError) as exc:
457
+ return err(str(exc))
458
+ if not patch.strip():
459
+ return ok("No changes.", empty=True)
460
+ truncated = patch[:60_000]
461
+ return ok(truncated, truncated=len(patch) > len(truncated), lines=patch.count("\n"))
462
+
463
+ @tool(
464
+ "list_changed_files",
465
+ "Paths that differ between two refs on the remote, e.g. a PR's base and head. "
466
+ "Read-only.",
467
+ {
468
+ "type": "object",
469
+ "required": ["base", "head"],
470
+ "properties": {
471
+ "base": {"type": "string", "description": "e.g. 'main'"},
472
+ "head": {"type": "string", "description": "e.g. 'fix/PROJ-1284'"},
473
+ },
474
+ },
475
+ )
476
+ async def list_changed_files(args: dict[str, Any]) -> dict[str, Any]:
477
+ try:
478
+ adapter = vcs()
479
+ except (VcsError, NotImplementedError, ValueError) as exc:
480
+ return err(str(exc))
481
+ unsupported = _remote_refusal(adapter, "list_changed_files")
482
+ if unsupported:
483
+ return err(unsupported)
484
+ try:
485
+ files = adapter.list_changed_files(str(args["base"]), str(args["head"]))
486
+ except (VcsError, NotImplementedError, ValueError, KeyError) as exc:
487
+ return err(str(exc))
488
+ return ok(", ".join(files) or "(no files changed)", files=files, count=len(files))
489
+
490
+ return [
491
+ current_branch,
492
+ create_branch,
493
+ write_file,
494
+ commit,
495
+ diff,
496
+ list_branches,
497
+ push,
498
+ open_pr,
499
+ pr_diff,
500
+ list_changed_files,
501
+ ]
502
+
503
+
504
+ def build(ctx: ToolContext):
505
+ """Construct the vcs MCP server bound to one agent's run context."""
506
+ return create_sdk_mcp_server(name="vcs", version="1.0.0", tools=build_tools(ctx))