qaas-python 0.0.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.
Files changed (96) hide show
  1. qaas/adapters/__init__.py +19 -0
  2. qaas/adapters/tracker.py +1783 -0
  3. qaas/adapters/vcs.py +555 -0
  4. qaas/cli.py +1757 -0
  5. qaas/config.py +409 -0
  6. qaas/defaults/config/agents/api.yaml +18 -0
  7. qaas/defaults/config/agents/architect.yaml +21 -0
  8. qaas/defaults/config/agents/auditor.yaml +19 -0
  9. qaas/defaults/config/agents/browser.yaml +15 -0
  10. qaas/defaults/config/agents/dba.yaml +20 -0
  11. qaas/defaults/config/agents/fixer.yaml +55 -0
  12. qaas/defaults/config/agents/guide.yaml +23 -0
  13. qaas/defaults/config/agents/load.yaml +26 -0
  14. qaas/defaults/config/agents/mapper.yaml +19 -0
  15. qaas/defaults/config/agents/reporter.yaml +19 -0
  16. qaas/defaults/config/agents/reproducer.yaml +21 -0
  17. qaas/defaults/config/agents/reviewer.yaml +18 -0
  18. qaas/defaults/config/agents/socket.yaml +23 -0
  19. qaas/defaults/config/agents/triage.yaml +20 -0
  20. qaas/defaults/config/agents/verifier.yaml +20 -0
  21. qaas/defaults/config/system.yaml +64 -0
  22. qaas/discover.py +242 -0
  23. qaas/envelope.py +318 -0
  24. qaas/envfile.py +100 -0
  25. qaas/guardrails.py +589 -0
  26. qaas/mcp/__init__.py +0 -0
  27. qaas/mcp/context.py +78 -0
  28. qaas/mcp/contract_diff.py +1011 -0
  29. qaas/mcp/defect_memory.py +495 -0
  30. qaas/mcp/env_control.py +925 -0
  31. qaas/mcp/envelope_server.py +463 -0
  32. qaas/mcp/test_runner.py +842 -0
  33. qaas/mcp/tracker.py +420 -0
  34. qaas/mcp/vcs.py +501 -0
  35. qaas/paths.py +317 -0
  36. qaas/plugin/.claude-plugin/plugin.json +9 -0
  37. qaas/plugin/skills/a11y-audit/SKILL.md +34 -0
  38. qaas/plugin/skills/adversarial-review/SKILL.md +120 -0
  39. qaas/plugin/skills/api-surface-extraction/SKILL.md +38 -0
  40. qaas/plugin/skills/authz-matrix-check/SKILL.md +46 -0
  41. qaas/plugin/skills/console-error-triage/SKILL.md +39 -0
  42. qaas/plugin/skills/contract-test-generation/SKILL.md +36 -0
  43. qaas/plugin/skills/dedupe-strategy/SKILL.md +39 -0
  44. qaas/plugin/skills/environment-pinning/SKILL.md +35 -0
  45. qaas/plugin/skills/error-taxonomy/SKILL.md +42 -0
  46. qaas/plugin/skills/exploratory-ui-walk/SKILL.md +46 -0
  47. qaas/plugin/skills/failing-test-authoring/SKILL.md +47 -0
  48. qaas/plugin/skills/flake-detection/SKILL.md +39 -0
  49. qaas/plugin/skills/form-state-probe/SKILL.md +36 -0
  50. qaas/plugin/skills/minimal-diff-discipline/SKILL.md +70 -0
  51. qaas/plugin/skills/openapi-diff/SKILL.md +45 -0
  52. qaas/plugin/skills/ownership-resolution/SKILL.md +31 -0
  53. qaas/plugin/skills/product-task-graph/SKILL.md +35 -0
  54. qaas/plugin/skills/regression-risk-scoring/SKILL.md +59 -0
  55. qaas/plugin/skills/regression-suite-selection/SKILL.md +36 -0
  56. qaas/plugin/skills/repo-cartography/SKILL.md +38 -0
  57. qaas/plugin/skills/repro-minimisation/SKILL.md +41 -0
  58. qaas/plugin/skills/rollback-plan-authoring/SKILL.md +81 -0
  59. qaas/plugin/skills/root-cause-vs-symptom/SKILL.md +67 -0
  60. qaas/plugin/skills/routing-rules/SKILL.md +34 -0
  61. qaas/plugin/skills/severity-rubric/SKILL.md +42 -0
  62. qaas/plugin/skills/test-first-fix/SKILL.md +66 -0
  63. qaas/plugin/skills/test-quality-audit/SKILL.md +58 -0
  64. qaas/plugin/skills/ticket-writer/SKILL.md +40 -0
  65. qaas/plugin/skills/verdict-reporting/SKILL.md +35 -0
  66. qaas/plugin/skills/verification-protocol/SKILL.md +39 -0
  67. qaas/prompts/API.md +44 -0
  68. qaas/prompts/ARCHITECT.md +80 -0
  69. qaas/prompts/AUDITOR.md +62 -0
  70. qaas/prompts/BROWSER.md +46 -0
  71. qaas/prompts/DBA.md +59 -0
  72. qaas/prompts/FIXER.md +55 -0
  73. qaas/prompts/GUIDE.md +94 -0
  74. qaas/prompts/LOAD.md +109 -0
  75. qaas/prompts/MAPPER.md +46 -0
  76. qaas/prompts/REPORTER.md +61 -0
  77. qaas/prompts/REPRODUCER.md +43 -0
  78. qaas/prompts/REVIEWER.md +53 -0
  79. qaas/prompts/SOCKET.md +100 -0
  80. qaas/prompts/TRIAGE.md +45 -0
  81. qaas/prompts/VERIFIER.md +41 -0
  82. qaas/prompts/_shared.md +45 -0
  83. qaas/registry.py +496 -0
  84. qaas/router.py +581 -0
  85. qaas/runner.py +210 -0
  86. qaas/scorecard.py +448 -0
  87. qaas/sdk_compat.py +52 -0
  88. qaas/store.py +323 -0
  89. qaas/target.py +287 -0
  90. qaas/tasks.py +438 -0
  91. qaas/trace.py +342 -0
  92. qaas_python-0.0.1.dist-info/METADATA +429 -0
  93. qaas_python-0.0.1.dist-info/RECORD +96 -0
  94. qaas_python-0.0.1.dist-info/WHEEL +4 -0
  95. qaas_python-0.0.1.dist-info/entry_points.txt +2 -0
  96. qaas_python-0.0.1.dist-info/licenses/LICENSE +21 -0
qaas/mcp/tracker.py ADDED
@@ -0,0 +1,420 @@
1
+ """The `tracker` MCP server — every ticket the system files passes through here.
2
+
3
+ The rules below are code, not prompt text, and that is deliberate. §8.1 says
4
+ only TRIAGE creates and only TRIAGE and VERIFIER transition; §4.12 caps tickets per
5
+ run and requires that hitting the cap escalates instead of filing; §10 lists
6
+ "security findings leak into public tickets" as a named failure mode. A prompt
7
+ can be argued with, misread, or dropped from a truncated context. A refusal
8
+ returned from the tool cannot.
9
+
10
+ Which backend actually stores the ticket is `config.tracker`'s business — see
11
+ `qaas.adapters.tracker`. Policy lives here so it holds identically for local
12
+ files and for real Jira.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ from collections.abc import Mapping
19
+ from typing import Any
20
+
21
+ from claude_agent_sdk import create_sdk_mcp_server, tool
22
+ from rich.console import Console
23
+
24
+ from qaas.adapters.tracker import (
25
+ DEFAULT_PROJECT,
26
+ LINK_TYPES,
27
+ SECURITY_PROJECT,
28
+ STATUSES,
29
+ TrackerError,
30
+ build_tracker,
31
+ issue_summary,
32
+ repo_label,
33
+ )
34
+ from qaas.envelope import DefectClass, DefectEnvelope
35
+ from qaas.mcp.context import ToolContext, err, ok
36
+
37
+ #: Set to 1 to rehearse every tracker write instead of performing it. This is
38
+ #: the safety rail for first contact with a real Jira: the policy above still
39
+ #: runs, the payload is still assembled, the ledger still records what would
40
+ #: have happened — and nothing is sent. Reads (`search`) stay live, because a
41
+ #: dedupe check that cannot see the real backlog rehearses the wrong run.
42
+ TRACKER_DRY_RUN_ENV = "QAAS_TRACKER_DRY_RUN"
43
+
44
+ #: Prefixed to every dry-run tool result. An agent that is told "filed CORVID-1"
45
+ #: will report a filed ticket, and a human will go looking for it; the notice
46
+ #: has to be the first thing in the result, not a footnote.
47
+ DRY_RUN_NOTICE = (
48
+ f"DRY RUN — NOTHING WAS FILED. {TRACKER_DRY_RUN_ENV}=1 is set, so the tracker rehearsed "
49
+ "this call and sent nothing to the backend. The key below is a placeholder and does not "
50
+ "exist. Do not report this as a filed ticket."
51
+ )
52
+
53
+ #: Dry-run lines go to stderr so an operator watching a run sees them without
54
+ #: them landing in anything that parses stdout.
55
+ _console = Console(stderr=True)
56
+
57
+
58
+ def dry_run_enabled(env: Mapping[str, str] | None = None) -> bool:
59
+ """Whether tracker writes are rehearsed rather than performed.
60
+
61
+ Read from the environment rather than config/ so that turning the rail off
62
+ is a deliberate act in the shell that starts the run, and so that a
63
+ committed config file can never quietly disable it.
64
+ """
65
+ source: Mapping[str, str] = os.environ if env is None else env
66
+ return (source.get(TRACKER_DRY_RUN_ENV) or "").strip().lower() in {"1", "true", "yes", "on"}
67
+
68
+
69
+ def is_restricted(envelope: DefectEnvelope) -> bool:
70
+ """Whether this finding may only be filed into the restricted project.
71
+
72
+ Two independent triggers, because either alone is enough to make a public
73
+ ticket a disclosure: the reporter flagged security impact, or the defect is
74
+ classified as a vulnerability.
75
+ """
76
+ return envelope.impact.security_relevant or envelope.defect_class == DefectClass.VULNERABILITY
77
+
78
+
79
+ CREATE_SCHEMA: dict[str, Any] = {
80
+ "type": "object",
81
+ "required": ["title", "body"],
82
+ "properties": {
83
+ "title": {"type": "string", "description": "Names the defect, not the symptom."},
84
+ "body": {
85
+ "type": "string",
86
+ "description": "House format: repro steps, evidence links, impact, acceptance criteria.",
87
+ },
88
+ "envelope_id": {
89
+ "type": "string",
90
+ "description": "The envelope this ticket files. Supply it: routing and severity are read from it.",
91
+ },
92
+ "project": {
93
+ "type": "string",
94
+ "description": f"Defaults to {DEFAULT_PROJECT}. Security findings are forced to {SECURITY_PROJECT}.",
95
+ },
96
+ "labels": {"type": "array", "items": {"type": "string"}},
97
+ "severity": {"type": "string", "enum": ["blocker", "critical", "major", "minor", "trivial"]},
98
+ "security_relevant": {
99
+ "type": "boolean",
100
+ "description": "Force restricted routing when no envelope carries the flag.",
101
+ },
102
+ },
103
+ }
104
+
105
+
106
+ def build_tools(ctx: ToolContext) -> list:
107
+ """The tracker tools, bound to one agent's run context.
108
+
109
+ Split from `build` so tests can call the handlers directly without standing
110
+ up an MCP transport.
111
+ """
112
+ # Built even in dry-run mode: constructing a JiraTracker is what validates
113
+ # the credentials, and a rehearsal against a configuration that could never
114
+ # have worked proves nothing.
115
+ tracker = build_tracker(ctx.config.tracker, ctx.store.root)
116
+ policy = ctx.agent.policy
117
+ dry_run = dry_run_enabled()
118
+
119
+ def deny(tool_name: str, reason: str) -> dict[str, Any]:
120
+ """Refuse, and leave a trace. A denial nobody can see is not a guardrail."""
121
+ ctx.store.log("denial", agent=ctx.agent.name, tool=tool_name, reason=reason)
122
+ return err(reason)
123
+
124
+ def rehearse(tool_name: str, line: str, **detail: Any) -> None:
125
+ """Record a write that was not performed, on the ledger and on stderr.
126
+
127
+ The ledger kind is `dry_run`, never `ticket`: anything counting filed
128
+ tickets must not count these, and a flag on a `ticket` entry is one
129
+ missed `if` away from being counted.
130
+ """
131
+ ctx.store.log("dry_run", agent=ctx.agent.name, tool=tool_name, **detail)
132
+ _console.print(f"[yellow]dry run[/yellow] {ctx.agent.name}: {line} [dim](not sent)[/dim]")
133
+
134
+ @tool(
135
+ "create_issue",
136
+ "File one tracker issue. Dedupe first — a duplicate costs the team more than a miss. "
137
+ "Security findings are routed to the restricted project automatically.",
138
+ CREATE_SCHEMA,
139
+ )
140
+ async def create_issue(args: dict[str, Any]) -> dict[str, Any]:
141
+ if not policy.may_create_tickets:
142
+ return deny(
143
+ "create_issue",
144
+ f"{ctx.agent.name} may not create tickets (§8.1: TRIAGE only). "
145
+ "Emit your finding as an envelope; TRIAGE files it.",
146
+ )
147
+
148
+ cap = policy.max_tickets_per_run
149
+ if ctx.count("tickets") >= cap:
150
+ ctx.store.log(
151
+ "escalation", agent=ctx.agent.name,
152
+ reason="ticket cap reached", cap=cap, tool="create_issue",
153
+ )
154
+ return deny(
155
+ "create_issue",
156
+ f"Ticket cap reached ({cap} for this run). Filing is disabled for the rest "
157
+ "of the run. Hitting the cap means something upstream is wrong, and forty "
158
+ "more tickets will not fix it: this is an escalation, not a filing problem. "
159
+ "Summarise what remains unfiled in your final message and stop.",
160
+ )
161
+
162
+ envelope = None
163
+ if args.get("envelope_id"):
164
+ envelope = ctx.store.get_envelope(args["envelope_id"])
165
+ if envelope is None:
166
+ return err(f"No envelope '{args['envelope_id']}' in this run. Emit it first.")
167
+
168
+ restricted = bool(args.get("security_relevant")) or (envelope is not None and is_restricted(envelope))
169
+ requested = (args.get("project") or "").strip() or None
170
+
171
+ # The project *names* come from the adapter, because a real Jira's keys
172
+ # are set by the deployment (JIRA_PROJECT_KEY / JIRA_SECURITY_PROJECT_KEY)
173
+ # rather than by the house constants. The routing *rule* is here, once,
174
+ # so it holds identically for local files and for Jira.
175
+ restricted_project = tracker.security_project
176
+
177
+ if restricted:
178
+ if restricted_project is None:
179
+ return deny(
180
+ "create_issue",
181
+ "Refused: this is a security-relevant finding and this tracker has no "
182
+ "restricted project configured (set JIRA_SECURITY_PROJECT_KEY to a "
183
+ f"project with restricted visibility). Filing it into "
184
+ f"'{tracker.default_project}' would be a disclosure, and there is no "
185
+ "undo (§4.12, §10). Escalate this finding to a human through a private "
186
+ "channel instead — do not file it anywhere.",
187
+ )
188
+ if requested and requested != restricted_project:
189
+ return deny(
190
+ "create_issue",
191
+ f"Refused: this is a security-relevant finding and '{requested}' is not the "
192
+ f"restricted project. Security findings never go to a public project (§4.12, "
193
+ f"§10) — file it into {restricted_project}, or omit `project` and it is routed "
194
+ "there for you.",
195
+ )
196
+ project = restricted_project
197
+ else:
198
+ project = requested or tracker.default_project
199
+
200
+ severity = args.get("severity") or (envelope.severity.value if envelope else None)
201
+ labels = list(args.get("labels") or [])
202
+ if "agent-found" not in labels:
203
+ labels.append("agent-found")
204
+ if restricted and "security" not in labels:
205
+ labels.append("security")
206
+ # Which repository this defect is in, stamped by the system rather than
207
+ # asked of the agent. It is the only thing that puts the ticket on that
208
+ # repository's board, and an agent that forgot it would file a ticket
209
+ # that exists and is invisible to the person watching.
210
+ repo = repo_label(ctx.config.target)
211
+ if repo and repo not in labels:
212
+ labels.append(repo)
213
+
214
+ if dry_run:
215
+ # The cap is still consumed: a rehearsal that ignores the rate limit
216
+ # is not a rehearsal of the run you are about to do.
217
+ n = ctx.bump("tickets")
218
+ key = f"{project}-{9000 + n}"
219
+ rehearse(
220
+ "create_issue",
221
+ f"would file '{args['title']}' in {project} as {key}",
222
+ action="create_issue", key=key, project=project, title=args["title"],
223
+ severity=severity, labels=sorted(labels), restricted=restricted,
224
+ envelope_id=envelope.id if envelope else None, count=n, cap=cap,
225
+ )
226
+ return ok(
227
+ f"{DRY_RUN_NOTICE} It would have filed '{args['title']}' into {project} "
228
+ f"(placeholder key {key}, severity {severity or 'unset'}, labels "
229
+ f"{', '.join(sorted(labels))}) — {n}/{cap} for this run.",
230
+ key=key, project=project, title=args["title"], severity=severity,
231
+ labels=sorted(labels), restricted=restricted,
232
+ envelope_id=envelope.id if envelope else None,
233
+ tickets_filed=n, tickets_cap=cap, dry_run=True, filed=False,
234
+ )
235
+
236
+ try:
237
+ issue = tracker.create_issue(
238
+ project=project,
239
+ title=args["title"],
240
+ body=args["body"],
241
+ labels=labels,
242
+ severity=severity,
243
+ envelope_id=envelope.id if envelope else None,
244
+ fingerprint=envelope.fingerprint() if envelope else None,
245
+ reporter=ctx.agent.name,
246
+ )
247
+ except TrackerError as exc:
248
+ return err(f"Tracker rejected the issue: {exc}")
249
+
250
+ # Stamp the key back onto the envelope. The two loops of this system meet
251
+ # at the ticket (§1) and this write is that junction: without it the
252
+ # remediation half can never find anything to work on, because it selects
253
+ # by `envelope.jira.key`. A whole full-loop run reached TRIAGE, filed ten
254
+ # tickets, and then skipped verification entirely for exactly this reason.
255
+ if envelope is not None:
256
+ ctx.store.put_envelope(
257
+ envelope.model_copy(
258
+ update={
259
+ "jira": envelope.jira.model_copy(
260
+ update={"key": issue.key, "project": project, "status": issue.status}
261
+ )
262
+ }
263
+ )
264
+ )
265
+
266
+ n = ctx.bump("tickets")
267
+ ctx.store.log(
268
+ "ticket", agent=ctx.agent.name, action="created", key=issue.key,
269
+ project=project, severity=severity, restricted=restricted,
270
+ envelope_id=issue.envelope_id, count=n, cap=cap,
271
+ )
272
+ routed = " Routed to the restricted project because it is security-relevant." if restricted else ""
273
+ return ok(
274
+ f"Filed {issue.key} in {project} ({n}/{cap} this run).{routed}",
275
+ **issue_summary(issue),
276
+ tickets_filed=n,
277
+ tickets_cap=cap,
278
+ restricted=restricted,
279
+ )
280
+
281
+ @tool(
282
+ "transition",
283
+ "Move an issue to a new status. Say why in the comment — the history is the audit trail.",
284
+ {
285
+ "type": "object",
286
+ "required": ["key", "status"],
287
+ "properties": {
288
+ "key": {"type": "string", "description": f"e.g. {DEFAULT_PROJECT}-12"},
289
+ "status": {"type": "string", "enum": list(STATUSES)},
290
+ "comment": {"type": "string", "description": "Why. Verdicts without reasons are not reviewable."},
291
+ },
292
+ },
293
+ )
294
+ async def transition(args: dict[str, Any]) -> dict[str, Any]:
295
+ if not policy.may_transition_tickets:
296
+ return deny(
297
+ "transition",
298
+ f"{ctx.agent.name} may not transition tickets (§8.1: TRIAGE and VERIFIER only).",
299
+ )
300
+
301
+ if dry_run:
302
+ # The issue is not read back either: in a rehearsal nothing was ever
303
+ # filed, so the key the agent holds is a placeholder and a lookup
304
+ # would fail for a reason that has nothing to do with the workflow.
305
+ rehearse(
306
+ "transition",
307
+ f"would move {args['key']} to '{args['status']}'",
308
+ action="transition", key=args["key"], status=args["status"],
309
+ comment=args.get("comment", ""),
310
+ )
311
+ return ok(
312
+ f"{DRY_RUN_NOTICE} It would have moved {args['key']} to '{args['status']}'"
313
+ + (f" with the comment: {args['comment']}" if args.get("comment") else "")
314
+ + ". The ticket's real status is unchanged.",
315
+ key=args["key"], status=args["status"], dry_run=True, filed=False,
316
+ )
317
+
318
+ try:
319
+ issue = tracker.transition(
320
+ args["key"], args["status"], by=ctx.agent.name, comment=args.get("comment", "")
321
+ )
322
+ except TrackerError as exc:
323
+ return err(f"Transition refused: {exc}")
324
+
325
+ ctx.store.log(
326
+ "ticket", agent=ctx.agent.name, action="transitioned",
327
+ key=issue.key, status=issue.status, comment=args.get("comment", ""),
328
+ )
329
+ return ok(f"{issue.key} is now '{issue.status}'.", **issue_summary(issue))
330
+
331
+ @tool(
332
+ "link",
333
+ "Link two existing issues — 'duplicates' for a repeat, 'regression-of' when a resolved "
334
+ "defect has come back.",
335
+ {
336
+ "type": "object",
337
+ "required": ["key", "to"],
338
+ "properties": {
339
+ "key": {"type": "string"},
340
+ "to": {"type": "string"},
341
+ "type": {"type": "string", "enum": list(LINK_TYPES), "default": "relates"},
342
+ },
343
+ },
344
+ )
345
+ async def link(args: dict[str, Any]) -> dict[str, Any]:
346
+ if not (policy.may_create_tickets or policy.may_transition_tickets):
347
+ return deny(
348
+ "link",
349
+ f"{ctx.agent.name} has no tracker write access, and a link is a write (§8.1).",
350
+ )
351
+
352
+ link_type = args.get("type") or "relates"
353
+ if dry_run:
354
+ rehearse(
355
+ "link",
356
+ f"would link {args['key']} {link_type} {args['to']}",
357
+ action="link", key=args["key"], to=args["to"], type=link_type,
358
+ )
359
+ return ok(
360
+ f"{DRY_RUN_NOTICE} It would have linked {args['key']} {link_type} "
361
+ f"{args['to']}. No link exists.",
362
+ key=args["key"], to=args["to"], type=link_type, dry_run=True, filed=False,
363
+ )
364
+
365
+ try:
366
+ issue = tracker.link(args["key"], args["to"], link_type)
367
+ except TrackerError as exc:
368
+ return err(f"Link refused: {exc}")
369
+ ctx.store.log(
370
+ "ticket", agent=ctx.agent.name, action="linked",
371
+ key=args["key"], to=args["to"], type=link_type,
372
+ )
373
+ return ok(f"{issue.key} {link_type} {args['to']}.", **issue_summary(issue))
374
+
375
+ @tool(
376
+ "search",
377
+ "Find existing issues before filing a new one, or to pick up work.",
378
+ {
379
+ "type": "object",
380
+ "properties": {
381
+ "text": {"type": "string", "description": "Substring of title or body."},
382
+ "project": {"type": "string"},
383
+ "status": {"type": "string", "enum": list(STATUSES)},
384
+ "label": {"type": "string"},
385
+ "envelope_id": {"type": "string"},
386
+ "fingerprint": {"type": "string"},
387
+ "limit": {"type": "integer", "minimum": 1, "maximum": 50},
388
+ },
389
+ },
390
+ )
391
+ async def search(args: dict[str, Any]) -> dict[str, Any]:
392
+ # Deliberately live even in dry-run mode: dedupe against an imaginary
393
+ # backlog would rehearse a run that files things the real one would not.
394
+ try:
395
+ issues = tracker.search(
396
+ text=args.get("text"),
397
+ project=args.get("project"),
398
+ status=args.get("status"),
399
+ label=args.get("label"),
400
+ envelope_id=args.get("envelope_id"),
401
+ fingerprint=args.get("fingerprint"),
402
+ limit=int(args.get("limit") or 20),
403
+ )
404
+ except TrackerError as exc:
405
+ return err(f"Search refused: {exc}")
406
+ if not issues:
407
+ return ok("No issues match.", issues=[], count=0)
408
+ lines = [f" {i.key} [{i.status}] {i.severity or '-'} {i.title}" for i in issues]
409
+ return ok(
410
+ f"{len(issues)} issue(s):\n" + "\n".join(lines),
411
+ issues=[issue_summary(i) for i in issues],
412
+ count=len(issues),
413
+ )
414
+
415
+ return [create_issue, transition, link, search]
416
+
417
+
418
+ def build(ctx: ToolContext):
419
+ """Construct the tracker MCP server bound to one agent's run context."""
420
+ return create_sdk_mcp_server(name="tracker", version="1.0.0", tools=build_tools(ctx))