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
@@ -0,0 +1,937 @@
1
+ """The `contract_diff` MCP server — what changed for the people calling you.
2
+
3
+ A text diff of two OpenAPI documents tells an agent that lines moved. It does
4
+ not tell it that `currency` vanished from the Invoice response and every
5
+ consumer reading that field now gets a KeyError. This server answers the second
6
+ question: it walks both documents structurally, resolves `$ref`s, and reports
7
+ changes as consumer-visible facts with a breaking/non-breaking verdict attached.
8
+
9
+ Two things follow from CONDUIT's brief (§4.5):
10
+
11
+ * **The declared contract is the reference.** `spec_a` defaults to
12
+ the target's declared spec and `spec_b` to the running app's `/openapi.json`,
13
+ so "drift" here means the implementation disagrees with the published spec —
14
+ which is the defect, not the other way round.
15
+ * **A finding ships with a failing test.** `generate_contract_test` emits a
16
+ standalone pytest module that asserts the spec's promises against a live
17
+ server. That file is the evidence an envelope cites; it must fail on the
18
+ violating implementation and pass on a conforming one, or it is worthless.
19
+
20
+ Classification follows one fixed rule set, applied identically by `diff_openapi`
21
+ and `classify_breaking` so the two can never disagree: losing a guarantee is
22
+ breaking (a removed endpoint or field, a dropped required-ness, a narrowed type,
23
+ a newly required request input, a changed status code); gaining an optional one
24
+ is not.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ import re
32
+ import urllib.error
33
+ import urllib.request
34
+ from pathlib import Path
35
+ from typing import Any, Iterable
36
+
37
+ import yaml
38
+ from claude_agent_sdk import create_sdk_mcp_server, tool
39
+
40
+ from qaas.mcp.context import ToolContext, err, ok
41
+
42
+ DEFAULT_SPEC_FILE = "openapi.yaml"
43
+ DEFAULT_LIVE_SPEC_URL = "http://localhost:8000/openapi.json"
44
+ FETCH_TIMEOUT_S = 10
45
+ GENERATED_DIR = "generated"
46
+
47
+ HTTP_METHODS = ("get", "put", "post", "delete", "patch", "options", "head", "trace")
48
+
49
+ #: Source extensions worth grepping for call sites. Everything else is noise.
50
+ CONSUMER_SUFFIXES = frozenset(
51
+ {".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".vue", ".svelte",
52
+ ".py", ".go", ".rb", ".java", ".kt", ".rs", ".php", ".cs", ".swift"}
53
+ )
54
+ SKIP_DIRS = frozenset({".git", ".venv", "node_modules", "__pycache__", "dist", "build", ".qaas", ".pytest_cache", ".mypy_cache"})
55
+ MAX_CONSUMER_HITS = 200
56
+
57
+ #: kind -> (verdict, why). The single source of truth for breaking-ness: every
58
+ #: change `diff_openapi` emits carries a kind from this table, and
59
+ #: `classify_breaking` is a lookup into it. One table, one answer.
60
+ RULES: dict[str, tuple[str, str]] = {
61
+ "endpoint_removed": ("breaking", "Consumers calling this endpoint now get a 404."),
62
+ "endpoint_added": ("non_breaking", "New surface; no existing caller is affected."),
63
+ "status_code_removed": ("breaking", "A documented outcome disappeared; consumers branching on it are wrong."),
64
+ "status_code_added": ("non_breaking", "An additional documented outcome; existing handling still applies."),
65
+ "response_field_removed": ("breaking", "Consumers reading this field get nothing back."),
66
+ "response_field_renamed": ("breaking", "A rename is a removal and an addition; every reader of the old name breaks."),
67
+ "response_field_added": ("non_breaking", "An added optional response field is ignored by existing consumers."),
68
+ "response_required_dropped": ("breaking", "The field was guaranteed present and no longer is."),
69
+ "response_required_added": ("non_breaking", "A field that was optional is now always present; that only helps."),
70
+ "response_type_narrowed": ("breaking", "The value set shrank; consumers may receive nothing they can use."),
71
+ "response_type_widened": ("non_breaking", "The declared value set grew; previously valid values still arrive."),
72
+ "response_enum_narrowed": ("breaking", "Documented values were withdrawn."),
73
+ "response_enum_widened": ("non_breaking", "Additional documented values. Consumers with exhaustive switches should still be told."),
74
+ "request_field_removed": ("breaking", "Requests that carried this field may now be rejected."),
75
+ "request_field_added_required": ("breaking", "Existing requests omit it and will now fail validation."),
76
+ "request_field_added_optional": ("non_breaking", "Existing requests remain valid."),
77
+ "request_required_added": ("breaking", "A previously optional input is now mandatory."),
78
+ "request_required_dropped": ("non_breaking", "Fewer inputs are mandatory; existing requests still validate."),
79
+ "request_type_narrowed": ("breaking", "Values that used to validate no longer do."),
80
+ "request_type_widened": ("non_breaking", "More values validate than before."),
81
+ "parameter_removed": ("breaking", "Callers passing this parameter silently lose the behaviour it controlled."),
82
+ "parameter_added_required": ("breaking", "Existing callers omit it and will now fail."),
83
+ "parameter_added_optional": ("non_breaking", "Existing callers are unaffected."),
84
+ "parameter_required_added": ("breaking", "A previously optional parameter is now mandatory."),
85
+ "parameter_type_narrowed": ("breaking", "Values callers already send may now be rejected."),
86
+ "parameter_type_widened": ("non_breaking", "More values are accepted than before."),
87
+ "security_added": ("breaking", "An endpoint that accepted anonymous calls now requires credentials."),
88
+ "security_removed": ("non_breaking", "Compatibility is unaffected, but dropping auth is a security finding in its own right."),
89
+ }
90
+
91
+
92
+ # --------------------------------------------------------------------------
93
+ # spec loading
94
+ # --------------------------------------------------------------------------
95
+
96
+
97
+ def _is_url(value: str) -> bool:
98
+ return value.startswith(("http://", "https://"))
99
+
100
+
101
+ def _fetch_spec(url: str) -> tuple[dict[str, Any] | None, str | None]:
102
+ try:
103
+ with urllib.request.urlopen(url, timeout=FETCH_TIMEOUT_S) as resp: # noqa: S310 - http(s) only, checked by caller
104
+ body = resp.read().decode(errors="replace")
105
+ except urllib.error.HTTPError as exc:
106
+ return None, f"HTTP {exc.code} from {url}"
107
+ except (urllib.error.URLError, OSError, TimeoutError, ValueError) as exc:
108
+ return None, f"could not reach {url} ({exc})"
109
+ try:
110
+ doc = json.loads(body)
111
+ except json.JSONDecodeError:
112
+ try:
113
+ doc = yaml.safe_load(body)
114
+ except yaml.YAMLError as exc:
115
+ return None, f"{url} returned something that is neither JSON nor YAML ({exc})"
116
+ return (doc, None) if isinstance(doc, dict) else (None, f"{url} did not return an object")
117
+
118
+
119
+ def _read_spec(ref: str, target_root: Path) -> tuple[dict[str, Any] | None, str | None]:
120
+ """Load a spec from a URL or a path. Returns (doc, error)."""
121
+ if _is_url(ref):
122
+ return _fetch_spec(ref)
123
+ path = Path(ref)
124
+ if not path.is_absolute():
125
+ path = target_root / path
126
+ if not path.exists():
127
+ return None, f"no such spec file: {path}"
128
+ try:
129
+ doc = yaml.safe_load(path.read_text())
130
+ except (OSError, yaml.YAMLError) as exc:
131
+ return None, f"could not parse {path}: {exc}"
132
+ return (doc, None) if isinstance(doc, dict) else (None, f"{path} does not contain an OpenAPI object")
133
+
134
+
135
+ # --------------------------------------------------------------------------
136
+ # schema walking
137
+ # --------------------------------------------------------------------------
138
+
139
+
140
+ def _resolve(node: Any, doc: dict[str, Any], seen: frozenset[str] = frozenset()) -> tuple[Any, frozenset[str]]:
141
+ """Follow local `$ref`s. Cycles stop at the second visit rather than recurse."""
142
+ guard = 0
143
+ while isinstance(node, dict) and "$ref" in node and guard < 20:
144
+ ref = str(node["$ref"])
145
+ if not ref.startswith("#/") or ref in seen:
146
+ return {}, seen
147
+ seen = seen | {ref}
148
+ target: Any = doc
149
+ for part in ref[2:].split("/"):
150
+ part = part.replace("~1", "/").replace("~0", "~")
151
+ if not isinstance(target, dict) or part not in target:
152
+ return {}, seen
153
+ target = target[part]
154
+ node = target
155
+ guard += 1
156
+ return node, seen
157
+
158
+
159
+ def _merged(schema: Any, doc: dict[str, Any], seen: frozenset[str]) -> tuple[dict[str, Any], frozenset[str]]:
160
+ """Resolve a schema and flatten a single level of allOf into it."""
161
+ schema, seen = _resolve(schema, doc, seen)
162
+ if not isinstance(schema, dict):
163
+ return {}, seen
164
+ if "allOf" not in schema:
165
+ return schema, seen
166
+ merged: dict[str, Any] = {k: v for k, v in schema.items() if k != "allOf"}
167
+ props: dict[str, Any] = dict(merged.get("properties") or {})
168
+ required: list[str] = list(merged.get("required") or [])
169
+ for part in schema["allOf"]:
170
+ sub, seen = _merged(part, doc, seen)
171
+ props.update(sub.get("properties") or {})
172
+ required.extend(sub.get("required") or [])
173
+ for key, value in sub.items():
174
+ if key not in ("properties", "required"):
175
+ merged.setdefault(key, value)
176
+ merged["properties"] = props
177
+ merged["required"] = sorted(set(required))
178
+ return merged, seen
179
+
180
+
181
+ def _types(schema: dict[str, Any]) -> frozenset[str]:
182
+ """The JSON types a schema admits, unioned across type lists and anyOf/oneOf."""
183
+ out: set[str] = set()
184
+ raw = schema.get("type")
185
+ if isinstance(raw, str):
186
+ out.add(raw)
187
+ elif isinstance(raw, list):
188
+ out.update(str(t) for t in raw)
189
+ for key in ("anyOf", "oneOf"):
190
+ for part in schema.get(key) or []:
191
+ if isinstance(part, dict):
192
+ out |= _types(part)
193
+ if not out and "properties" in schema:
194
+ out.add("object")
195
+ if not out and "items" in schema:
196
+ out.add("array")
197
+ return frozenset(out)
198
+
199
+
200
+ def _enum(schema: dict[str, Any]) -> frozenset[str] | None:
201
+ values = schema.get("enum")
202
+ if isinstance(values, list):
203
+ return frozenset(json.dumps(v, sort_keys=True) for v in values)
204
+ for key in ("anyOf", "oneOf"):
205
+ collected: set[str] = set()
206
+ for part in schema.get(key) or []:
207
+ sub = _enum(part) if isinstance(part, dict) else None
208
+ if sub:
209
+ collected |= set(sub)
210
+ if collected:
211
+ return frozenset(collected)
212
+ return None
213
+
214
+
215
+ def flatten_schema(schema: Any, doc: dict[str, Any], prefix: str = "", *, depth: int = 0,
216
+ seen: frozenset[str] = frozenset()) -> dict[str, dict[str, Any]]:
217
+ """Field path -> {types, enum, required}. Arrays flatten as `items[].field`.
218
+
219
+ Dotted paths are what a consumer actually reads (`items[].currency`), so a
220
+ diff expressed over them lands in the same vocabulary as the bug report.
221
+ """
222
+ out: dict[str, dict[str, Any]] = {}
223
+ if depth > 12:
224
+ return out
225
+ node, seen = _merged(schema, doc, seen)
226
+ if not isinstance(node, dict):
227
+ return out
228
+
229
+ if "items" in node:
230
+ out.update(flatten_schema(node["items"], doc, f"{prefix}[]", depth=depth + 1, seen=seen))
231
+
232
+ required = set(node.get("required") or [])
233
+ for name, sub in (node.get("properties") or {}).items():
234
+ path = f"{prefix}.{name}" if prefix else str(name)
235
+ resolved, sub_seen = _merged(sub, doc, seen)
236
+ out[path] = {
237
+ "types": _types(resolved),
238
+ "enum": _enum(resolved),
239
+ "required": name in required,
240
+ }
241
+ out.update(flatten_schema(resolved, doc, path, depth=depth + 1, seen=sub_seen))
242
+ return out
243
+
244
+
245
+ def _json_schema(container: Any, doc: dict[str, Any]) -> Any:
246
+ """The JSON body schema out of a responses/requestBody entry, if there is one."""
247
+ node, _ = _resolve(container, doc)
248
+ if not isinstance(node, dict):
249
+ return None
250
+ content = node.get("content")
251
+ if not isinstance(content, dict):
252
+ return None
253
+ for media, spec in content.items():
254
+ if "json" in str(media):
255
+ return (spec or {}).get("schema")
256
+ first = next(iter(content.values()), None)
257
+ return (first or {}).get("schema") if isinstance(first, dict) else None
258
+
259
+
260
+ def _operations(doc: dict[str, Any]) -> dict[tuple[str, str], dict[str, Any]]:
261
+ """(path, METHOD) -> operation, with path-level parameters folded in."""
262
+ ops: dict[tuple[str, str], dict[str, Any]] = {}
263
+ for path, item in (doc.get("paths") or {}).items():
264
+ if not isinstance(item, dict):
265
+ continue
266
+ shared = item.get("parameters") or []
267
+ for method in HTTP_METHODS:
268
+ op = item.get(method)
269
+ if not isinstance(op, dict):
270
+ continue
271
+ merged = dict(op)
272
+ merged["parameters"] = list(shared) + list(op.get("parameters") or [])
273
+ ops[(str(path), method.upper())] = merged
274
+ return ops
275
+
276
+
277
+ def _requires_auth(op: dict[str, Any], doc: dict[str, Any]) -> bool:
278
+ security = op.get("security", doc.get("security", []))
279
+ return bool(security) and any(bool(entry) for entry in security)
280
+
281
+
282
+ def _parameters(op: dict[str, Any], doc: dict[str, Any]) -> dict[tuple[str, str], dict[str, Any]]:
283
+ out: dict[tuple[str, str], dict[str, Any]] = {}
284
+ for raw in op.get("parameters") or []:
285
+ param, _ = _resolve(raw, doc)
286
+ if not isinstance(param, dict) or "name" not in param:
287
+ continue
288
+ schema, _ = _merged(param.get("schema") or {}, doc, frozenset())
289
+ out[(str(param.get("in", "query")), str(param["name"]))] = {
290
+ "required": bool(param.get("required", False)),
291
+ "types": _types(schema),
292
+ "enum": _enum(schema),
293
+ }
294
+ return out
295
+
296
+
297
+ # --------------------------------------------------------------------------
298
+ # the diff itself
299
+ # --------------------------------------------------------------------------
300
+
301
+
302
+ def _change(kind: str, path: str, method: str, detail: str) -> dict[str, Any]:
303
+ verdict, _ = RULES.get(kind, ("unknown", ""))
304
+ return {"kind": kind, "path": path, "method": method, "detail": detail, "breaking": verdict == "breaking"}
305
+
306
+
307
+ def _kind(prefix: str, facet: str, direction: str) -> str:
308
+ """`response_enum_widened` if that rule exists, else the `_type_` equivalent.
309
+
310
+ Only responses get their own enum rules; for requests and parameters an enum
311
+ change is just a change to the admissible value space, which is what the
312
+ type rules already say.
313
+ """
314
+ candidate = f"{prefix}_{facet}_{direction}"
315
+ return candidate if candidate in RULES else f"{prefix}_type_{direction}"
316
+
317
+
318
+ def _compare_value_space(a: dict[str, Any], b: dict[str, Any], prefix: str) -> str | None:
319
+ """The widened/narrowed kind for a field's value space, or None if unchanged.
320
+
321
+ Direction is decided by subset relation, not by name: only a strict shrink of
322
+ the admissible values can break a consumer that already works. An unrelated
323
+ change (string -> integer, one enum swapped for another) counts as narrowing,
324
+ because at least one value the consumer handled is now impossible.
325
+ """
326
+ enum_a, enum_b = a.get("enum"), b.get("enum")
327
+ if enum_a != enum_b:
328
+ if enum_a is None: # was unconstrained, now restricted
329
+ return _kind(prefix, "enum", "narrowed")
330
+ if enum_b is None: # was restricted, now open
331
+ return _kind(prefix, "enum", "widened")
332
+ return _kind(prefix, "enum", "widened" if enum_a < enum_b else "narrowed")
333
+
334
+ types_a, types_b = a.get("types") or frozenset(), b.get("types") or frozenset()
335
+ if types_a == types_b:
336
+ return None
337
+ if types_a and types_b and types_a < types_b:
338
+ return f"{prefix}_type_widened"
339
+ return f"{prefix}_type_narrowed"
340
+
341
+
342
+ def _detect_renames(removed: list[str], added: list[str], fields_a: dict[str, dict[str, Any]],
343
+ fields_b: dict[str, dict[str, Any]]) -> list[tuple[str, str]]:
344
+ """Pair a removal with an addition when the sibling and shape both match.
345
+
346
+ Heuristic, deliberately conservative: a pair is only a rename when exactly
347
+ one candidate matches, so an object that lost two fields and gained two is
348
+ reported as four changes rather than two invented renames.
349
+ """
350
+ pairs: list[tuple[str, str]] = []
351
+ taken: set[str] = set()
352
+ for old in removed:
353
+ parent = old.rsplit(".", 1)[0] if "." in old else ""
354
+ shape = (fields_a[old]["types"], fields_a[old]["enum"], fields_a[old]["required"])
355
+ candidates = [
356
+ new for new in added
357
+ if new not in taken
358
+ and (new.rsplit(".", 1)[0] if "." in new else "") == parent
359
+ and (fields_b[new]["types"], fields_b[new]["enum"], fields_b[new]["required"]) == shape
360
+ ]
361
+ if len(candidates) == 1:
362
+ taken.add(candidates[0])
363
+ pairs.append((old, candidates[0]))
364
+ return pairs
365
+
366
+
367
+ def diff_specs(spec_a: dict[str, Any], spec_b: dict[str, Any]) -> list[dict[str, Any]]:
368
+ """Structural diff of two OpenAPI documents, A being the reference."""
369
+ ops_a, ops_b = _operations(spec_a), _operations(spec_b)
370
+ changes: list[dict[str, Any]] = []
371
+
372
+ for key in sorted(set(ops_a) - set(ops_b)):
373
+ changes.append(_change("endpoint_removed", key[0], key[1], f"{key[1]} {key[0]} is declared in A but absent from B."))
374
+ for key in sorted(set(ops_b) - set(ops_a)):
375
+ changes.append(_change("endpoint_added", key[0], key[1], f"{key[1]} {key[0]} exists in B but is undeclared in A."))
376
+
377
+ for key in sorted(set(ops_a) & set(ops_b)):
378
+ path, method = key
379
+ op_a, op_b = ops_a[key], ops_b[key]
380
+ changes.extend(_diff_security(op_a, op_b, spec_a, spec_b, path, method))
381
+ changes.extend(_diff_parameters(op_a, op_b, spec_a, spec_b, path, method))
382
+ changes.extend(_diff_request(op_a, op_b, spec_a, spec_b, path, method))
383
+ changes.extend(_diff_responses(op_a, op_b, spec_a, spec_b, path, method))
384
+
385
+ return changes
386
+
387
+
388
+ def _diff_security(op_a: dict[str, Any], op_b: dict[str, Any], doc_a: dict[str, Any], doc_b: dict[str, Any],
389
+ path: str, method: str) -> list[dict[str, Any]]:
390
+ auth_a, auth_b = _requires_auth(op_a, doc_a), _requires_auth(op_b, doc_b)
391
+ if auth_a == auth_b:
392
+ return []
393
+ kind = "security_added" if auth_b else "security_removed"
394
+ verb = "now requires" if auth_b else "no longer requires"
395
+ return [_change(kind, path, method, f"{method} {path} {verb} authentication.")]
396
+
397
+
398
+ def _diff_parameters(op_a: dict[str, Any], op_b: dict[str, Any], doc_a: dict[str, Any], doc_b: dict[str, Any],
399
+ path: str, method: str) -> list[dict[str, Any]]:
400
+ params_a, params_b = _parameters(op_a, doc_a), _parameters(op_b, doc_b)
401
+ changes: list[dict[str, Any]] = []
402
+ for key in sorted(set(params_a) - set(params_b)):
403
+ changes.append(_change("parameter_removed", path, method, f"{key[0]} parameter '{key[1]}' was removed."))
404
+ for key in sorted(set(params_b) - set(params_a)):
405
+ kind = "parameter_added_required" if params_b[key]["required"] else "parameter_added_optional"
406
+ changes.append(_change(kind, path, method, f"{key[0]} parameter '{key[1]}' was added."))
407
+ for key in sorted(set(params_a) & set(params_b)):
408
+ a, b = params_a[key], params_b[key]
409
+ if not a["required"] and b["required"]:
410
+ changes.append(_change("parameter_required_added", path, method, f"{key[0]} parameter '{key[1]}' became required."))
411
+ kind = _compare_value_space(a, b, "parameter")
412
+ if kind:
413
+ changes.append(_change(kind, path, method,
414
+ f"{key[0]} parameter '{key[1]}': {_describe(a)} -> {_describe(b)}."))
415
+ return changes
416
+
417
+
418
+ def _diff_request(op_a: dict[str, Any], op_b: dict[str, Any], doc_a: dict[str, Any], doc_b: dict[str, Any],
419
+ path: str, method: str) -> list[dict[str, Any]]:
420
+ schema_a = _json_schema(op_a.get("requestBody") or {}, doc_a)
421
+ schema_b = _json_schema(op_b.get("requestBody") or {}, doc_b)
422
+ if schema_a is None and schema_b is None:
423
+ return []
424
+ fields_a = flatten_schema(schema_a or {}, doc_a)
425
+ fields_b = flatten_schema(schema_b or {}, doc_b)
426
+ changes: list[dict[str, Any]] = []
427
+ for name in sorted(set(fields_a) - set(fields_b)):
428
+ changes.append(_change("request_field_removed", path, method, f"request field '{name}' was removed."))
429
+ for name in sorted(set(fields_b) - set(fields_a)):
430
+ kind = "request_field_added_required" if fields_b[name]["required"] else "request_field_added_optional"
431
+ changes.append(_change(kind, path, method, f"request field '{name}' was added."))
432
+ for name in sorted(set(fields_a) & set(fields_b)):
433
+ a, b = fields_a[name], fields_b[name]
434
+ if not a["required"] and b["required"]:
435
+ changes.append(_change("request_required_added", path, method, f"request field '{name}' became required."))
436
+ elif a["required"] and not b["required"]:
437
+ changes.append(_change("request_required_dropped", path, method, f"request field '{name}' is no longer required."))
438
+ kind = _compare_value_space(a, b, "request")
439
+ if kind:
440
+ changes.append(_change(kind, path, method, f"request field '{name}': {_describe(a)} -> {_describe(b)}."))
441
+ return changes
442
+
443
+
444
+ def _diff_responses(op_a: dict[str, Any], op_b: dict[str, Any], doc_a: dict[str, Any], doc_b: dict[str, Any],
445
+ path: str, method: str) -> list[dict[str, Any]]:
446
+ responses_a = {str(k): v for k, v in (op_a.get("responses") or {}).items()}
447
+ responses_b = {str(k): v for k, v in (op_b.get("responses") or {}).items()}
448
+ changes: list[dict[str, Any]] = []
449
+
450
+ for code in sorted(set(responses_a) - set(responses_b)):
451
+ changes.append(_change("status_code_removed", path, method, f"documented status {code} is gone."))
452
+ for code in sorted(set(responses_b) - set(responses_a)):
453
+ changes.append(_change("status_code_added", path, method, f"status {code} is documented in B only."))
454
+
455
+ for code in sorted(set(responses_a) & set(responses_b)):
456
+ schema_a = _json_schema(responses_a[code], doc_a)
457
+ schema_b = _json_schema(responses_b[code], doc_b)
458
+ if schema_a is None and schema_b is None:
459
+ continue
460
+ fields_a = flatten_schema(schema_a or {}, doc_a)
461
+ fields_b = flatten_schema(schema_b or {}, doc_b)
462
+ removed = sorted(set(fields_a) - set(fields_b))
463
+ added = sorted(set(fields_b) - set(fields_a))
464
+ renamed = _detect_renames(removed, added, fields_a, fields_b)
465
+ renamed_old = {old for old, _ in renamed}
466
+ renamed_new = {new for _, new in renamed}
467
+
468
+ for old, new in renamed:
469
+ changes.append(_change("response_field_renamed", path, method,
470
+ f"{code} response field '{old}' appears to have been renamed to '{new}'."))
471
+ for name in removed:
472
+ if name in renamed_old:
473
+ continue
474
+ qualifier = "required " if fields_a[name]["required"] else ""
475
+ changes.append(_change("response_field_removed", path, method,
476
+ f"{code} response is missing the {qualifier}field '{name}' the reference declares."))
477
+ for name in added:
478
+ if name in renamed_new:
479
+ continue
480
+ qualifier = "required" if fields_b[name]["required"] else "optional"
481
+ changes.append(_change("response_field_added", path, method,
482
+ f"{code} response gained the {qualifier} field '{name}'."))
483
+ for name in sorted(set(fields_a) & set(fields_b)):
484
+ a, b = fields_a[name], fields_b[name]
485
+ if a["required"] and not b["required"]:
486
+ changes.append(_change("response_required_dropped", path, method,
487
+ f"{code} response field '{name}' is no longer guaranteed present."))
488
+ elif not a["required"] and b["required"]:
489
+ changes.append(_change("response_required_added", path, method,
490
+ f"{code} response field '{name}' is now always present."))
491
+ kind = _compare_value_space(a, b, "response")
492
+ if kind:
493
+ changes.append(_change(kind, path, method,
494
+ f"{code} response field '{name}': {_describe(a)} -> {_describe(b)}."))
495
+ return changes
496
+
497
+
498
+ def _describe(field: dict[str, Any]) -> str:
499
+ types = "/".join(sorted(field.get("types") or [])) or "any"
500
+ enum = field.get("enum")
501
+ if enum:
502
+ # Enum members are stored as canonical JSON so they can be set-compared;
503
+ # decode them again for a message a human reads.
504
+ values = sorted(str(json.loads(v)) for v in enum)
505
+ return f"{types} enum[{', '.join(values)}]"
506
+ return types
507
+
508
+
509
+ # --------------------------------------------------------------------------
510
+ # consumers and generated tests
511
+ # --------------------------------------------------------------------------
512
+
513
+
514
+ def _split_endpoint(raw: str) -> tuple[str | None, str]:
515
+ """'GET /v1/orders' -> ('GET', '/v1/orders'); a bare path -> (None, path)."""
516
+ parts = raw.strip().split()
517
+ if len(parts) == 2 and parts[0].upper() in {m.upper() for m in HTTP_METHODS}:
518
+ return parts[0].upper(), parts[1]
519
+ return None, parts[-1] if parts else raw.strip()
520
+
521
+
522
+ def _search_terms(path: str) -> list[str]:
523
+ """Literal needles for a templated path: the whole thing and its stable prefix."""
524
+ terms = {path}
525
+ head = path.split("{", 1)[0].rstrip("/")
526
+ if head and head != path:
527
+ terms.add(head)
528
+ return sorted(terms, key=len, reverse=True)
529
+
530
+
531
+ def _walk_sources(root: Path) -> Iterable[Path]:
532
+ for dirpath, dirnames, filenames in os.walk(root):
533
+ dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS and not d.startswith(".")]
534
+ for name in filenames:
535
+ if Path(name).suffix in CONSUMER_SUFFIXES:
536
+ yield Path(dirpath) / name
537
+
538
+
539
+ def _identifier(method: str, path: str) -> str:
540
+ slug = re.sub(r"[^a-z0-9]+", "_", path.lower()).strip("_")
541
+ return f"{method.lower()}_{slug}" or "endpoint"
542
+
543
+
544
+ def _required_fields(schema: Any, doc: dict[str, Any]) -> tuple[list[str], list[str]]:
545
+ """(top-level required fields, required fields of `items[]`) for a response schema."""
546
+ fields = flatten_schema(schema or {}, doc)
547
+ top = sorted(n for n, f in fields.items() if f["required"] and "." not in n and "[]" not in n)
548
+ item = sorted(
549
+ n.split("items[].", 1)[1]
550
+ for n, f in fields.items()
551
+ if f["required"] and n.startswith("items[].") and "." not in n.split("items[].", 1)[1]
552
+ )
553
+ return top, item
554
+
555
+
556
+ TEST_TEMPLATE = '''"""Contract test for {method} {path} — generated by CONDUIT from {spec_name}.
557
+
558
+ {why}
559
+
560
+ Runs against a live server: set QAAS_TARGET_BASE_URL (default {default_base}).
561
+ Supply QAAS_TARGET_TOKEN to skip the login round-trip. Standard library only, so
562
+ it runs anywhere pytest does — including in a fix branch's CI.
563
+ """
564
+
565
+ import json
566
+ import os
567
+ import urllib.error
568
+ import urllib.request
569
+
570
+ import pytest
571
+
572
+ BASE_URL = os.environ.get("QAAS_TARGET_BASE_URL", "{default_base}").rstrip("/")
573
+ PATH = {path!r}
574
+ METHOD = {method!r}
575
+ EXPECTED_STATUS = {expected_status}
576
+ REQUIRED_TOP_LEVEL_FIELDS = {required_top!r}
577
+ REQUIRED_ITEM_FIELDS = {required_item!r}
578
+ AUTH_REQUIRED = {auth_required!r}
579
+ LOGIN_EMAIL = "admin@northwind.test"
580
+ LOGIN_PASSWORD = "password123"
581
+
582
+
583
+ def _call(path, method="GET", token=None, body=None):
584
+ data = json.dumps(body).encode() if body is not None else None
585
+ headers = {{"Accept": "application/json"}}
586
+ if data is not None:
587
+ headers["Content-Type"] = "application/json"
588
+ if token:
589
+ headers["Authorization"] = "Bearer " + token
590
+ request = urllib.request.Request(BASE_URL + path, data=data, headers=headers, method=method)
591
+ try:
592
+ with urllib.request.urlopen(request, timeout=15) as resp:
593
+ return resp.status, resp.read().decode(errors="replace")
594
+ except urllib.error.HTTPError as exc:
595
+ return exc.code, exc.read().decode(errors="replace")
596
+ except (urllib.error.URLError, OSError) as exc:
597
+ pytest.fail("target app unreachable at " + BASE_URL + ": " + str(exc))
598
+
599
+
600
+ @pytest.fixture(scope="module")
601
+ def token():
602
+ if not AUTH_REQUIRED:
603
+ return None
604
+ preset = os.environ.get("QAAS_TARGET_TOKEN")
605
+ if preset:
606
+ return preset
607
+ status, body = _call("/v1/auth/login", "POST", body={{"email": LOGIN_EMAIL, "password": LOGIN_PASSWORD}})
608
+ assert status == 200, "could not log in to fetch a token: HTTP %s %s" % (status, body[:300])
609
+ return json.loads(body)["access_token"]
610
+
611
+
612
+ @pytest.fixture(scope="module")
613
+ def response(token):
614
+ status, body = _call(PATH, METHOD, token=token)
615
+ return status, body
616
+
617
+
618
+ def test_status_code_matches_the_spec(response):
619
+ status, body = response
620
+ assert status == EXPECTED_STATUS, (
621
+ "%s %s: spec declares %s, server returned %s. Body: %s"
622
+ % (METHOD, PATH, EXPECTED_STATUS, status, body[:300])
623
+ )
624
+
625
+
626
+ def test_response_is_json(response):
627
+ _, body = response
628
+ try:
629
+ json.loads(body)
630
+ except json.JSONDecodeError:
631
+ pytest.fail("%s %s did not return JSON: %s" % (METHOD, PATH, body[:300]))
632
+
633
+
634
+ def test_required_response_fields_are_present(response):
635
+ if not REQUIRED_TOP_LEVEL_FIELDS:
636
+ pytest.skip("the spec declares no required top-level response fields")
637
+ _, body = response
638
+ payload = json.loads(body)
639
+ missing = [name for name in REQUIRED_TOP_LEVEL_FIELDS if name not in payload]
640
+ assert not missing, (
641
+ "%s %s response is missing spec-required field(s): %s"
642
+ % (METHOD, PATH, ", ".join(missing))
643
+ )
644
+
645
+
646
+ def test_collection_items_carry_required_fields(response):
647
+ if not REQUIRED_ITEM_FIELDS:
648
+ pytest.skip("this response is not a collection with a declared item schema")
649
+ _, body = response
650
+ payload = json.loads(body)
651
+ items = payload.get("items") if isinstance(payload, dict) else None
652
+ if not items:
653
+ pytest.skip("no items returned; seed the fixture to exercise this assertion")
654
+ missing = sorted({{name for item in items for name in REQUIRED_ITEM_FIELDS if name not in item}})
655
+ assert not missing, (
656
+ "%s %s items are missing spec-required field(s): %s"
657
+ % (METHOD, PATH, ", ".join(missing))
658
+ )
659
+
660
+
661
+ def test_endpoint_requires_authentication():
662
+ if not AUTH_REQUIRED:
663
+ pytest.skip("the spec marks this endpoint as public")
664
+ status, body = _call(PATH, METHOD)
665
+ assert status == 401, (
666
+ "%s %s is declared as authenticated but answered an anonymous call with %s"
667
+ % (METHOD, PATH, status)
668
+ )
669
+ '''
670
+
671
+
672
+ # --------------------------------------------------------------------------
673
+ # tools
674
+ # --------------------------------------------------------------------------
675
+
676
+
677
+ def build_tools(ctx: ToolContext) -> list:
678
+ """The contract_diff tools, bound to one agent's run context.
679
+
680
+ Split from `build` so tests can call the handlers directly without standing
681
+ up an MCP transport.
682
+ """
683
+
684
+ # `layout.spec` has existed since the schema was written and nothing read
685
+ # it, so every target was assumed to keep an `openapi.yaml` at its root.
686
+ _profile = getattr(ctx.config, "profile", None)
687
+ _declared_spec = getattr(getattr(_profile, "layout", None), "spec", None) if _profile else None
688
+ default_spec = ctx.target_root / (_declared_spec or DEFAULT_SPEC_FILE)
689
+ _spec_label = str(_declared_spec or DEFAULT_SPEC_FILE)
690
+
691
+ @tool(
692
+ "diff_openapi",
693
+ "Compare two OpenAPI documents semantically. Defaults to the declared contract "
694
+ f"({_spec_label}) against the running app's /openapi.json, so a reported change "
695
+ "means the implementation disagrees with the spec. Pass file paths when the app is down.",
696
+ {
697
+ "type": "object",
698
+ "properties": {
699
+ "spec_a": {"type": "string", "description": "Reference spec: a path or an http(s) URL. Default: the declared openapi.yaml."},
700
+ "spec_b": {"type": "string", "description": "Spec under test. Default: the running app's /openapi.json."},
701
+ "only_breaking": {"type": "boolean", "description": "Return only changes classified breaking."},
702
+ },
703
+ },
704
+ )
705
+ async def diff_openapi(args: dict[str, Any]) -> dict[str, Any]:
706
+ ref_a = str(args.get("spec_a") or default_spec)
707
+ live_default = os.environ.get("QAAS_TARGET_BASE_URL")
708
+ live_default = f"{live_default.rstrip('/')}/openapi.json" if live_default else DEFAULT_LIVE_SPEC_URL
709
+ ref_b = str(args.get("spec_b") or live_default)
710
+
711
+ doc_a, problem = _read_spec(ref_a, ctx.target_root)
712
+ if problem:
713
+ return err(f"Could not load spec_a: {problem}")
714
+ doc_b, problem = _read_spec(ref_b, ctx.target_root)
715
+ if problem:
716
+ hint = (
717
+ " The target app does not appear to be running. Start it with env_control.spin_up, "
718
+ "or pass spec_b as a file path to compare two documents on disk."
719
+ if _is_url(ref_b) else ""
720
+ )
721
+ return err(f"Could not load spec_b: {problem}.{hint}")
722
+
723
+ assert doc_a is not None and doc_b is not None
724
+ try:
725
+ changes = diff_specs(doc_a, doc_b)
726
+ except RecursionError:
727
+ return err("The specs contain a recursion this differ cannot follow; compare a subset of paths.")
728
+
729
+ if args.get("only_breaking"):
730
+ changes = [c for c in changes if c["breaking"]]
731
+ breaking = [c for c in changes if c["breaking"]]
732
+
733
+ if not changes:
734
+ return ok(f"No semantic differences between {ref_a} and {ref_b}.", changes=[], breaking_count=0)
735
+
736
+ lines = [f"{'BREAKING' if c['breaking'] else 'compatible'} {c['method']} {c['path']} [{c['kind']}] {c['detail']}"
737
+ for c in changes[:120]]
738
+ more = f"\n(+{len(changes) - 120} more)" if len(changes) > 120 else ""
739
+ return ok(
740
+ f"{len(changes)} change(s), {len(breaking)} breaking, comparing {ref_a} (reference) "
741
+ f"against {ref_b}:\n" + "\n".join(lines) + more,
742
+ changes=changes, breaking_count=len(breaking), spec_a=ref_a, spec_b=ref_b,
743
+ )
744
+
745
+ @tool(
746
+ "classify_breaking",
747
+ "Classify one change as breaking, non_breaking or unknown, with the consumer-impact reason. "
748
+ "Pass a change object from diff_openapi.",
749
+ {
750
+ "type": "object",
751
+ "required": ["change"],
752
+ "properties": {
753
+ "change": {
754
+ "type": "object",
755
+ "description": "A change from diff_openapi: {kind, path, method, detail}.",
756
+ "properties": {
757
+ "kind": {"type": "string"},
758
+ "path": {"type": "string"},
759
+ "method": {"type": "string"},
760
+ "detail": {"type": "string"},
761
+ },
762
+ }
763
+ },
764
+ },
765
+ )
766
+ async def classify_breaking(args: dict[str, Any]) -> dict[str, Any]:
767
+ change = args.get("change")
768
+ if not isinstance(change, dict):
769
+ change = {k: v for k, v in args.items() if k != "change"}
770
+ kind = str(change.get("kind") or "").strip()
771
+ if not kind:
772
+ return err(
773
+ "The change has no 'kind'. Pass a change object exactly as diff_openapi returned it; "
774
+ f"recognised kinds are: {', '.join(sorted(RULES))}."
775
+ )
776
+ verdict, reason = RULES.get(kind, (
777
+ "unknown",
778
+ f"'{kind}' is not a kind this server emits, so its consumer impact cannot be decided here. "
779
+ "Judge it by hand, or re-run diff_openapi and classify one of its changes.",
780
+ ))
781
+ where = f"{change.get('method', '')} {change.get('path', '')}".strip()
782
+ return ok(
783
+ f"{kind}{f' on {where}' if where else ''}: {verdict}. {reason}",
784
+ verdict=verdict, reason=reason, kind=kind, breaking=verdict == "breaking",
785
+ )
786
+
787
+ @tool(
788
+ "find_consumers",
789
+ "Find likely call sites of an endpoint in this repository, across its declared frontend roots. "
790
+ "Heuristic: it matches the literal path string and the stable prefix before any {param}, "
791
+ "so a client that assembles its URL from fragments will be missed and an unrelated string "
792
+ "that happens to contain the path will be reported.",
793
+ {
794
+ "type": "object",
795
+ "required": ["endpoint"],
796
+ "properties": {
797
+ "endpoint": {"type": "string", "description": "'GET /v1/orders' or just '/v1/orders'."},
798
+ "root": {"type": "string", "description": "Subdirectory to search, repo-relative. Default: the whole repo."},
799
+ },
800
+ },
801
+ )
802
+ async def find_consumers(args: dict[str, Any]) -> dict[str, Any]:
803
+ _, path = _split_endpoint(str(args["endpoint"]))
804
+ if not path.startswith("/"):
805
+ return err(f"'{args['endpoint']}' does not name a path. Use 'GET /v1/orders' or '/v1/orders'.")
806
+
807
+ root = ctx.target_root
808
+ if args.get("root"):
809
+ candidate = (ctx.target_root / str(args["root"])).resolve()
810
+ if not candidate.is_relative_to(ctx.target_root.resolve()):
811
+ return err("root must stay inside the repository.")
812
+ if not candidate.exists():
813
+ return err(f"No such directory: {candidate}.")
814
+ root = candidate
815
+
816
+ terms = _search_terms(path)
817
+ hits: list[dict[str, Any]] = []
818
+ for file in _walk_sources(root):
819
+ try:
820
+ text = file.read_text(errors="replace")
821
+ except OSError:
822
+ continue
823
+ if not any(term in text for term in terms):
824
+ continue
825
+ for number, line in enumerate(text.splitlines(), start=1):
826
+ if any(term in line for term in terms):
827
+ hits.append({
828
+ "file": str(file.relative_to(ctx.target_root)) if file.is_relative_to(ctx.target_root) else str(file),
829
+ "line": number,
830
+ "text": line.strip()[:200],
831
+ })
832
+ if len(hits) >= MAX_CONSUMER_HITS:
833
+ break
834
+ if len(hits) >= MAX_CONSUMER_HITS:
835
+ break
836
+
837
+ if not hits:
838
+ return ok(
839
+ f"No call sites matched {path} under {root}. That is weak evidence: a client that builds "
840
+ "the URL from fragments would not match. Check by hand before claiming no consumers exist.",
841
+ consumers=[], searched=str(root), terms=terms,
842
+ )
843
+ listing = "\n".join(f"{h['file']}:{h['line']} {h['text']}" for h in hits)
844
+ return ok(f"{len(hits)} possible call site(s) for {path} (heuristic):\n{listing}",
845
+ consumers=hits, searched=str(root), terms=terms)
846
+
847
+ @tool(
848
+ "generate_contract_test",
849
+ "Emit a runnable pytest module asserting the spec's contract for one endpoint: status code, "
850
+ "required response fields, and the auth requirement. Written under .qaas/generated/ and stored "
851
+ "as an artifact so the envelope can cite it. Run it before filing — a contract test that does "
852
+ "not fail against the current implementation is not evidence.",
853
+ {
854
+ "type": "object",
855
+ "required": ["endpoint", "method"],
856
+ "properties": {
857
+ "endpoint": {"type": "string", "description": "Spec path, e.g. '/v1/invoices'."},
858
+ "method": {"type": "string", "description": "HTTP method, e.g. 'GET'."},
859
+ "expectation": {"type": "string", "description": "Why you are generating it — what you expect to break. Recorded in the test's docstring."},
860
+ "spec": {"type": "string", "description": f"Spec to read the contract from. Default: {_spec_label}."},
861
+ "expected_status": {"type": "integer", "description": "Override the success status. Default: the first 2xx in the spec."},
862
+ },
863
+ },
864
+ )
865
+ async def generate_contract_test(args: dict[str, Any]) -> dict[str, Any]:
866
+ ref = str(args.get("spec") or default_spec)
867
+ doc, problem = _read_spec(ref, ctx.target_root)
868
+ if problem:
869
+ return err(f"Could not load the spec: {problem}")
870
+ assert doc is not None
871
+
872
+ _, path = _split_endpoint(str(args["endpoint"]))
873
+ method = str(args["method"]).upper()
874
+ if "{" in path:
875
+ return err(
876
+ f"{path} has path parameters this generator cannot fill in — it would have to invent "
877
+ "an id, and a test that 404s proves nothing. Generate a test for a collection endpoint, "
878
+ "or write the parameterised case by hand."
879
+ )
880
+ ops = _operations(doc)
881
+ op = ops.get((path, method))
882
+ if op is None:
883
+ known = ", ".join(f"{m} {p}" for p, m in sorted(ops)[:25])
884
+ return err(f"{method} {path} is not in {ref}. It declares: {known}")
885
+
886
+ responses = {str(k): v for k, v in (op.get("responses") or {}).items()}
887
+ override = args.get("expected_status")
888
+ if override is not None:
889
+ expected = int(override)
890
+ if str(expected) not in responses:
891
+ return err(f"{method} {path} does not declare status {expected}; it declares {', '.join(sorted(responses))}.")
892
+ else:
893
+ success = sorted(c for c in responses if c.isdigit() and 200 <= int(c) < 300)
894
+ if not success:
895
+ return err(f"{method} {path} declares no 2xx response, so there is no success contract to assert.")
896
+ expected = int(success[0])
897
+
898
+ required_top, required_item = _required_fields(_json_schema(responses[str(expected)], doc), doc)
899
+ source = TEST_TEMPLATE.format(
900
+ method=method,
901
+ path=path,
902
+ spec_name=Path(ref).name if not _is_url(ref) else ref,
903
+ # The expectation is free text from an agent and lands inside a
904
+ # docstring, so a stray triple quote would produce a file that will
905
+ # not import. Neutralise it rather than reject the call.
906
+ why=str(args.get("expectation") or "Asserts the published contract for this endpoint.").strip().replace('"""', "'''").replace("\\", "/"),
907
+ default_base=os.environ.get("QAAS_TARGET_BASE_URL", "http://localhost:8000").rstrip("/"),
908
+ expected_status=expected,
909
+ required_top=required_top,
910
+ required_item=required_item,
911
+ auth_required=_requires_auth(op, doc),
912
+ )
913
+
914
+ out_dir = ctx.store.root / GENERATED_DIR
915
+ out_dir.mkdir(parents=True, exist_ok=True)
916
+ filename = f"test_contract_{_identifier(method, path)}.py"
917
+ target = out_dir / filename
918
+ target.write_text(source)
919
+ uri = ctx.store.put_artifact(filename, source)
920
+ ctx.store.log("contract_test", agent=ctx.agent.name, endpoint=f"{method} {path}", path=str(target))
921
+
922
+ return ok(
923
+ f"Wrote {target}. It asserts HTTP {expected}"
924
+ + (f", required response fields {', '.join(required_top)}" if required_top else "")
925
+ + (f", required item fields {', '.join(required_item)}" if required_item else "")
926
+ + (", and that anonymous calls get 401" if _requires_auth(op, doc) else "")
927
+ + f". Run it with QAAS_TARGET_BASE_URL set, then cite {uri} as evidence.\n\n{source}",
928
+ path=str(target), uri=uri, source=source, expected_status=expected,
929
+ required_fields=required_top, required_item_fields=required_item,
930
+ )
931
+
932
+ return [diff_openapi, classify_breaking, find_consumers, generate_contract_test]
933
+
934
+
935
+ def build(ctx: ToolContext):
936
+ """Construct the contract_diff MCP server bound to one agent's run context."""
937
+ return create_sdk_mcp_server(name="contract_diff", version="1.0.0", tools=build_tools(ctx))