spring-runtime-mcp 0.2.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 (118) hide show
  1. spring_runtime_mcp/__init__.py +3 -0
  2. spring_runtime_mcp/adapters/__init__.py +1 -0
  3. spring_runtime_mcp/adapters/beans.py +159 -0
  4. spring_runtime_mcp/adapters/conditions.py +333 -0
  5. spring_runtime_mcp/adapters/config.py +295 -0
  6. spring_runtime_mcp/adapters/drift.py +790 -0
  7. spring_runtime_mcp/adapters/health.py +164 -0
  8. spring_runtime_mcp/adapters/mapping_predicates.py +201 -0
  9. spring_runtime_mcp/adapters/mappings.py +294 -0
  10. spring_runtime_mcp/adapters/migrations.py +373 -0
  11. spring_runtime_mcp/adapters/openapi.py +683 -0
  12. spring_runtime_mcp/adapters/source_catalog.py +119 -0
  13. spring_runtime_mcp/adapters/startup.py +202 -0
  14. spring_runtime_mcp/analyzers/__init__.py +1 -0
  15. spring_runtime_mcp/analyzers/bean_graph.py +172 -0
  16. spring_runtime_mcp/analyzers/condition_failures.py +359 -0
  17. spring_runtime_mcp/cli.py +248 -0
  18. spring_runtime_mcp/contracts/__init__.py +1 -0
  19. spring_runtime_mcp/contracts/definitions.py +425 -0
  20. spring_runtime_mcp/diagnostics.py +254 -0
  21. spring_runtime_mcp/models/__init__.py +1 -0
  22. spring_runtime_mcp/models/bean_graph.py +77 -0
  23. spring_runtime_mcp/models/common.py +99 -0
  24. spring_runtime_mcp/models/conditions.py +56 -0
  25. spring_runtime_mcp/models/queries.py +140 -0
  26. spring_runtime_mcp/models/source_catalog.py +35 -0
  27. spring_runtime_mcp/models/timestamps.py +71 -0
  28. spring_runtime_mcp/policy/__init__.py +1 -0
  29. spring_runtime_mcp/policy/configuration.py +32 -0
  30. spring_runtime_mcp/policy/output_guard.py +117 -0
  31. spring_runtime_mcp/policy/sanitization.py +616 -0
  32. spring_runtime_mcp/py.typed +0 -0
  33. spring_runtime_mcp/replay.py +220 -0
  34. spring_runtime_mcp/replay_data/bean_graph/README.md +26 -0
  35. spring_runtime_mcp/replay_data/bean_graph/factory-method/conditions.json +43 -0
  36. spring_runtime_mcp/replay_data/bean_graph/factory-method/source-catalog.json +54 -0
  37. spring_runtime_mcp/replay_data/bean_graph/nested-conditions/beans.json +99 -0
  38. spring_runtime_mcp/replay_data/bean_graph/nested-conditions/conditions.json +25 -0
  39. spring_runtime_mcp/replay_data/bean_graph/parent-child/beans.json +92 -0
  40. spring_runtime_mcp/replay_data/bean_graph/shape-metadata.json +32 -0
  41. spring_runtime_mcp/replay_data/bean_graph/stripe-missing/conditions.json +43 -0
  42. spring_runtime_mcp/replay_data/bean_graph/stripe-missing/source-catalog.json +54 -0
  43. spring_runtime_mcp/replay_data/conformance/README.md +41 -0
  44. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/beans.json +92 -0
  45. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/conditions.json +52 -0
  46. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/configprops.json +20 -0
  47. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/env.json +16 -0
  48. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/flyway.json +40 -0
  49. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v2.json +14 -0
  50. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v3.json +38 -0
  51. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/liquibase.json +30 -0
  52. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/mappings.json +40 -0
  53. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/openapi.json +54 -0
  54. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/source-catalog.json +90 -0
  55. spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/startup.json +45 -0
  56. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/beans.json +92 -0
  57. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/conditions.json +52 -0
  58. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/configprops.json +20 -0
  59. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/env.json +16 -0
  60. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/flyway.json +40 -0
  61. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/health-v3.json +38 -0
  62. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/liquibase.json +30 -0
  63. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/mappings.json +40 -0
  64. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/openapi.json +54 -0
  65. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/source-catalog.json +90 -0
  66. spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/startup.json +45 -0
  67. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/beans.json +92 -0
  68. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/conditions.json +52 -0
  69. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/configprops.json +20 -0
  70. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/env.json +16 -0
  71. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/flyway.json +40 -0
  72. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/health-v3.json +38 -0
  73. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/liquibase.json +30 -0
  74. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/mappings.json +40 -0
  75. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/openapi.json +54 -0
  76. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/servlet-namespace-comparison.json +4 -0
  77. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/source-catalog.json +90 -0
  78. spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/startup.json +45 -0
  79. spring_runtime_mcp/replay_data/conformance/contracts/springboot_analyze_startup.json +62 -0
  80. spring_runtime_mcp/replay_data/conformance/contracts/springboot_check_migrations.json +63 -0
  81. spring_runtime_mcp/replay_data/conformance/contracts/springboot_detect_drift.json +64 -0
  82. spring_runtime_mcp/replay_data/conformance/contracts/springboot_diagnose_health.json +59 -0
  83. spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_beans.json +56 -0
  84. spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_config.json +62 -0
  85. spring_runtime_mcp/replay_data/conformance/contracts/springboot_search_openapi.json +80 -0
  86. spring_runtime_mcp/replay_data/conformance/contracts/springboot_verify_mappings.json +58 -0
  87. spring_runtime_mcp/replay_data/conformance/faults/cases.json +63 -0
  88. spring_runtime_mcp/replay_data/conformance/faults/health-hidden.expected.json +54 -0
  89. spring_runtime_mcp/replay_data/conformance/faults/health-hidden.json +3 -0
  90. spring_runtime_mcp/replay_data/conformance/large/beans-2500.json +22506 -0
  91. spring_runtime_mcp/replay_data/conformance/manifest.json +46 -0
  92. spring_runtime_mcp/schemas/tools-contract.json +1902 -0
  93. spring_runtime_mcp/server.py +322 -0
  94. spring_runtime_mcp/services.py +44 -0
  95. spring_runtime_mcp/snapshots.py +340 -0
  96. spring_runtime_mcp/tools/__init__.py +1 -0
  97. spring_runtime_mcp/tools/analyze_startup.py +79 -0
  98. spring_runtime_mcp/tools/check_migrations.py +99 -0
  99. spring_runtime_mcp/tools/detect_drift.py +323 -0
  100. spring_runtime_mcp/tools/diagnose_health.py +88 -0
  101. spring_runtime_mcp/tools/inspect_beans.py +479 -0
  102. spring_runtime_mcp/tools/inspect_config.py +102 -0
  103. spring_runtime_mcp/tools/registry.py +48 -0
  104. spring_runtime_mcp/tools/search_openapi.py +79 -0
  105. spring_runtime_mcp/tools/support.py +166 -0
  106. spring_runtime_mcp/tools/verify_mappings.py +62 -0
  107. spring_runtime_mcp/transport/__init__.py +1 -0
  108. spring_runtime_mcp/transport/actuator_http.py +459 -0
  109. spring_runtime_mcp/transport/bounded_json.py +144 -0
  110. spring_runtime_mcp/transport/replay_files.py +71 -0
  111. spring_runtime_mcp/transport/response_gate.py +186 -0
  112. spring_runtime_mcp/transport/responses.py +82 -0
  113. spring_runtime_mcp/transport/stdio.py +44 -0
  114. spring_runtime_mcp-0.2.0.dist-info/METADATA +320 -0
  115. spring_runtime_mcp-0.2.0.dist-info/RECORD +118 -0
  116. spring_runtime_mcp-0.2.0.dist-info/WHEEL +4 -0
  117. spring_runtime_mcp-0.2.0.dist-info/entry_points.txt +2 -0
  118. spring_runtime_mcp-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1 @@
1
+ """Tool orchestration independent of a specific MCP host."""
@@ -0,0 +1,79 @@
1
+ """Read-only startup timeline handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from ..adapters.startup import normalize_startup
10
+ from ..contracts.definitions import validate_arguments
11
+ from ..models.common import NormalizationError
12
+ from ..models.queries import StartupQuery
13
+ from ..transport.response_gate import EndpointReadError
14
+ from ..services import Services
15
+ from ..diagnostics import failed, partial
16
+ from .support import tool_handler, finalize, read_checked, resume, shell, stamp
17
+
18
+
19
+ @tool_handler
20
+ async def analyze_startup(
21
+ arguments: Mapping[str, Any], services: Services
22
+ ) -> dict[str, Any]:
23
+ request_target = "local"
24
+ try:
25
+ query = validate_arguments(arguments, "springboot_analyze_startup")
26
+ request_target = query.target_id
27
+ assert isinstance(query, StartupQuery)
28
+ if query.cursor:
29
+ return resume(services, query)
30
+ snapshot = uuid4().hex
31
+ observation = await read_checked(services, query.target_id, "startup", snapshot)
32
+ version, total, basis, complete, items, gaps = normalize_startup(
33
+ observation.body,
34
+ step_name_pattern=query.step_name_pattern,
35
+ bean_name_pattern=query.bean_name_pattern,
36
+ min_duration_ms=query.min_duration_ms,
37
+ max_depth=query.max_depth,
38
+ )
39
+ result = shell(query.target_id, "registered target startup", "startup-1")
40
+ stamp(result, observation, "startup", snapshot)
41
+ for index, item in enumerate(items, start=1):
42
+ item["id"] = f"startup_{index:06d}"
43
+ item["endpointId"] = "startup"
44
+ item["observationId"] = observation.observation_id
45
+ result["data"] = {
46
+ "springBootVersion": version,
47
+ "totalDurationMs": total,
48
+ "durationBasis": basis,
49
+ "timelineComplete": complete,
50
+ "items": items,
51
+ }
52
+ limited = {
53
+ "STARTUP_NODE_LIMIT",
54
+ "STARTUP_TAG_LIMIT",
55
+ "STARTUP_DEPTH_LIMIT",
56
+ } & set(gaps)
57
+ result["totalMatched"] = None if limited else len(items)
58
+ if limited:
59
+ result["truncated"] = True
60
+ result["truncationReasons"] = sorted(
61
+ set(result["truncationReasons"] + list(limited))
62
+ )[:8]
63
+ if not complete:
64
+ gaps = [*gaps, "STARTUP_TIMELINE_INCOMPLETE"]
65
+ partial(result, gaps)
66
+ return finalize(result, services, query)
67
+ except NormalizationError as exc:
68
+ return failed(
69
+ exc.code,
70
+ request_target,
71
+ endpoint=exc.endpoint_id if isinstance(exc, EndpointReadError) else None,
72
+ http_status=exc.http_status if isinstance(exc, EndpointReadError) else None,
73
+ )
74
+ except TimeoutError:
75
+ return failed("TARGET_TIMEOUT", request_target)
76
+ except (ValueError, TypeError, KeyError, RecursionError, UnicodeError):
77
+ return failed("MALFORMED_EVIDENCE", request_target)
78
+ except Exception:
79
+ return failed("INTERNAL_ERROR", request_target)
@@ -0,0 +1,99 @@
1
+ """Read-only Flyway and Liquibase handler. It never executes migrations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from ..adapters.migrations import normalize_migrations
10
+ from ..contracts.definitions import validate_arguments
11
+ from ..models.common import NormalizationError
12
+ from ..models.queries import MigrationQuery
13
+ from ..transport.response_gate import EndpointReadError
14
+ from ..services import Services
15
+ from ..diagnostics import error, failed, partial, safe_code
16
+ from .support import tool_handler, finalize, read_checked, resume, shell, stamp
17
+
18
+
19
+ @tool_handler
20
+ async def check_migrations(
21
+ arguments: Mapping[str, Any], services: Services
22
+ ) -> dict[str, Any]:
23
+ request_target = "local"
24
+ try:
25
+ query = validate_arguments(arguments, "springboot_check_migrations")
26
+ request_target = query.target_id
27
+ assert isinstance(query, MigrationQuery)
28
+ if query.cursor:
29
+ return resume(services, query)
30
+ snapshot = uuid4().hex
31
+ if (
32
+ query.target_id not in services.targets
33
+ or services.catalog.target_id != query.target_id
34
+ ):
35
+ raise NormalizationError("TARGET_NOT_REGISTERED")
36
+ engines = ("flyway", "liquibase") if query.engine == "auto" else (query.engine,)
37
+ bodies: dict[str, Any] = {}
38
+ observation_ids: dict[str, str] = {}
39
+ result = shell(query.target_id, "registered target migrations", "migrations-1")
40
+ gaps: list[str] = []
41
+ for endpoint in engines:
42
+ try:
43
+ observation = await read_checked(
44
+ services, query.target_id, endpoint, snapshot
45
+ )
46
+ except NormalizationError as exc:
47
+ gaps.append(safe_code(exc.code))
48
+ result["errors"].append(
49
+ error(
50
+ exc.code,
51
+ endpoint,
52
+ exc.http_status if isinstance(exc, EndpointReadError) else None,
53
+ )
54
+ )
55
+ continue
56
+ bodies[endpoint] = observation.body
57
+ observation_ids[endpoint] = observation.observation_id
58
+ stamp(result, observation, endpoint, snapshot)
59
+ if not bodies and result["errors"]:
60
+ return failed(
61
+ result["errors"][0]["code"],
62
+ query.target_id,
63
+ endpoint=result["errors"][0]["endpointId"],
64
+ http_status=result["errors"][0]["httpStatus"],
65
+ )
66
+ items, parsed_gaps, visibility = normalize_migrations(
67
+ bodies.get("flyway"), bodies.get("liquibase"), query
68
+ )
69
+ gaps.extend(parsed_gaps)
70
+ invalid = [
71
+ engine
72
+ for engine in bodies
73
+ if engine.upper() + "_EVIDENCE_INVALID" in parsed_gaps
74
+ ]
75
+ if invalid and len(invalid) == len(bodies):
76
+ return failed("MALFORMED_EVIDENCE", query.target_id, endpoint=invalid[0])
77
+ for engine in invalid:
78
+ result["errors"].append(error("MALFORMED_EVIDENCE", engine))
79
+ for item in items:
80
+ item["observationId"] = observation_ids.get(
81
+ item["endpointId"], "observation_baseline"
82
+ )
83
+ result["data"] = {"pendingVisibility": visibility, "items": items}
84
+ result["totalMatched"] = None if result["errors"] or gaps else len(items)
85
+ partial(result, gaps)
86
+ return finalize(result, services, query)
87
+ except NormalizationError as exc:
88
+ return failed(
89
+ exc.code,
90
+ request_target,
91
+ endpoint=exc.endpoint_id if isinstance(exc, EndpointReadError) else None,
92
+ http_status=exc.http_status if isinstance(exc, EndpointReadError) else None,
93
+ )
94
+ except TimeoutError:
95
+ return failed("TARGET_TIMEOUT", request_target)
96
+ except (ValueError, TypeError, KeyError, RecursionError, UnicodeError):
97
+ return failed("MALFORMED_EVIDENCE", request_target)
98
+ except Exception:
99
+ return failed("INTERNAL_ERROR", request_target)
@@ -0,0 +1,323 @@
1
+ """Compare caller-supplied expectations with a read-only runtime snapshot."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Mapping
7
+ from typing import Any
8
+ from uuid import uuid4
9
+
10
+ from ..adapters.drift import compare_drift
11
+ from ..contracts.definitions import validate_arguments
12
+ from ..models.common import NormalizationError
13
+ from ..models.queries import DriftQuery
14
+ from ..models.timestamps import parse_rfc3339
15
+ from ..transport.response_gate import EndpointReadError
16
+ from ..services import Services
17
+ from ..diagnostics import error, failed, partial, safe_code
18
+ from .support import tool_handler, finalize, read_checked, resume, shell, stamp
19
+
20
+ _VERSION = re.compile(r"[A-Za-z0-9_.-]{1,64}\Z")
21
+ _STARTUP_UNAVAILABLE = frozenset(
22
+ {"CAPABILITY_DISABLED", "ENDPOINT_NOT_EXPOSED", "STARTUP_DATA_UNAVAILABLE"}
23
+ )
24
+ _STARTUP_EVIDENCE_GAP = "STARTUP_EVIDENCE_UNAVAILABLE"
25
+ _DIFF_FIELDS = (
26
+ "id",
27
+ "dimension",
28
+ "identity",
29
+ "contextId",
30
+ "expected",
31
+ "actual",
32
+ "evidenceLevel",
33
+ "affectedDependencyIds",
34
+ )
35
+
36
+
37
+ def _profile_evidence(body: Any, services: Services) -> Any:
38
+ """Profiles permission does not grant unregistered property evidence."""
39
+ if not isinstance(body, dict) or services.config_policy is None:
40
+ return None
41
+ sources = []
42
+ for source in (
43
+ body.get("propertySources", [])
44
+ if isinstance(body.get("propertySources"), list)
45
+ else []
46
+ ):
47
+ if not isinstance(source, dict) or not isinstance(
48
+ source.get("properties"), dict
49
+ ):
50
+ continue
51
+ sources.append(
52
+ {
53
+ "name": source.get("name"),
54
+ "properties": {
55
+ key: value
56
+ for key, value in source["properties"].items()
57
+ if services.config_policy.allows(key)
58
+ },
59
+ }
60
+ )
61
+ return {"activeProfiles": body.get("activeProfiles"), "propertySources": sources}
62
+
63
+
64
+ def _project(page: dict[str, Any]) -> dict[str, Any]:
65
+ data = page.get("data")
66
+ if not isinstance(data, dict) or "items" not in data:
67
+ return page
68
+ missing: list[dict[str, Any]] = []
69
+ unexpected: list[dict[str, Any]] = []
70
+ for item in data.pop("items") or []:
71
+ if not isinstance(item, dict):
72
+ continue
73
+ record = {field: item.get(field) for field in _DIFF_FIELDS}
74
+ if not isinstance(record["affectedDependencyIds"], list):
75
+ record["affectedDependencyIds"] = []
76
+ if str(record.get("id") or "").startswith("u"):
77
+ unexpected.append(record)
78
+ else:
79
+ missing.append(record)
80
+ data["missingInRuntime"] = missing
81
+ data["unexpectedInRuntime"] = unexpected
82
+ data.setdefault("profileMismatch", None)
83
+ data.setdefault("rootCauseInferences", [])
84
+ return page
85
+
86
+
87
+ def _startup_start(body: Any) -> str | None:
88
+ if not isinstance(body, dict) or not isinstance(body.get("timeline"), dict):
89
+ return None
90
+ start = body["timeline"].get("startTime")
91
+ return (
92
+ start if isinstance(start, str) and parse_rfc3339(start) is not None else None
93
+ )
94
+
95
+
96
+ def _later(modified: str, started: str) -> bool:
97
+ left, right = parse_rfc3339(modified), parse_rfc3339(started)
98
+ return left is not None and right is not None and left > right
99
+
100
+
101
+ def _inferences(
102
+ missing: list[dict[str, Any]],
103
+ unexpected: list[dict[str, Any]],
104
+ profile: dict[str, Any] | None,
105
+ *,
106
+ environment_override: bool,
107
+ not_restarted: bool,
108
+ ) -> list[dict[str, Any]]:
109
+ evidence = [row["id"] for row in (*missing, *unexpected)][:8]
110
+ drifted = bool(
111
+ missing or unexpected or (profile and profile.get("state") == "mismatch")
112
+ )
113
+ rows: list[dict[str, Any]] = []
114
+ if profile and profile.get("state") == "mismatch":
115
+ rows.append(
116
+ {"code": "PROFILE_MISMATCH", "confidence": "supported", "evidenceIds": []}
117
+ )
118
+ if environment_override:
119
+ rows.append(
120
+ {
121
+ "code": "ENVIRONMENT_OVERRIDE",
122
+ "confidence": "supported",
123
+ "evidenceIds": [],
124
+ }
125
+ )
126
+ if not_restarted and drifted:
127
+ rows.append(
128
+ {
129
+ "code": "POSSIBLE_NOT_RESTARTED",
130
+ "confidence": "candidate",
131
+ "evidenceIds": list(evidence),
132
+ }
133
+ )
134
+ if missing or unexpected:
135
+ rows.append(
136
+ {
137
+ "code": "INSUFFICIENT_EVIDENCE",
138
+ "confidence": "unknown",
139
+ "evidenceIds": list(evidence),
140
+ }
141
+ )
142
+ return rows[:4]
143
+
144
+
145
+ def _assign(rows: list[dict[str, Any]], prefix: str) -> None:
146
+ for index, row in enumerate(rows, start=1):
147
+ row["id"] = f"{prefix}{index:06d}"
148
+
149
+
150
+ async def _optional_startup(
151
+ services: Services, query: DriftQuery, snapshot: str, result: dict[str, Any]
152
+ ) -> tuple[Any | None, list[dict[str, Any]], list[str]]:
153
+ if query.source_modified_at is None:
154
+ return None, [], []
155
+ try:
156
+ observation = await read_checked(services, query.target_id, "startup", snapshot)
157
+ except NormalizationError as exc:
158
+ if safe_code(exc.code) in _STARTUP_UNAVAILABLE:
159
+ # The caller explicitly supplied sourceModifiedAt, so startup timing
160
+ # is required evidence. Missing it is a gap, never a silent success.
161
+ return None, [], [_STARTUP_EVIDENCE_GAP]
162
+ return (
163
+ None,
164
+ [
165
+ error(
166
+ exc.code,
167
+ "startup",
168
+ exc.http_status if isinstance(exc, EndpointReadError) else None,
169
+ )
170
+ ],
171
+ [],
172
+ )
173
+ stamp(result, observation, "startup", snapshot)
174
+ version = (
175
+ observation.body.get("springBootVersion")
176
+ if isinstance(observation.body, dict)
177
+ else None
178
+ )
179
+ if isinstance(version, str) and _VERSION.fullmatch(version):
180
+ result["source"]["bootVersion"] = version
181
+ return observation.body, [], []
182
+
183
+
184
+ @tool_handler
185
+ async def detect_drift(
186
+ arguments: Mapping[str, Any], services: Services
187
+ ) -> dict[str, Any]:
188
+ request_target = "local"
189
+ try:
190
+ query = validate_arguments(arguments, "springboot_detect_drift")
191
+ request_target = query.target_id
192
+ assert isinstance(query, DriftQuery)
193
+ # A caller revision is not an observed build id. Do not infer
194
+ # POSSIBLE_WRONG_INSTANCE unless a future authorized info read proves it.
195
+ if query.source_revision is not None and not isinstance(
196
+ query.source_revision, str
197
+ ):
198
+ raise NormalizationError("INVALID_ARGUMENT")
199
+ if query.cursor:
200
+ return _project(resume(services, query))
201
+ snapshot = uuid4().hex
202
+ if (
203
+ query.target_id not in services.targets
204
+ or services.catalog.target_id != query.target_id
205
+ ):
206
+ raise NormalizationError("TARGET_NOT_REGISTERED")
207
+ result = shell(query.target_id, "registered-target-drift", "drift-1")
208
+ plan: list[str] = []
209
+ if query.expected_beans is not None:
210
+ plan.append("beans")
211
+ if query.expected_routes is not None:
212
+ plan.append("mappings")
213
+ if query.expected_migration is not None:
214
+ plan.append(query.expected_migration.engine)
215
+ if query.expected_profiles is not None:
216
+ plan.append("env")
217
+ bodies: dict[str, Any] = {}
218
+ failures: list[dict[str, Any]] = []
219
+ for endpoint in plan:
220
+ try:
221
+ if endpoint == "env" and (
222
+ services.config_policy is None
223
+ or not services.config_policy.profiles
224
+ ):
225
+ raise EndpointReadError("CAPABILITY_DISABLED", "env")
226
+ observation = await read_checked(
227
+ services, query.target_id, endpoint, snapshot
228
+ )
229
+ except NormalizationError as exc:
230
+ failures.append(
231
+ error(
232
+ exc.code,
233
+ endpoint,
234
+ exc.http_status if isinstance(exc, EndpointReadError) else None,
235
+ )
236
+ )
237
+ continue
238
+ bodies[endpoint] = observation
239
+ stamp(result, observation, endpoint, snapshot)
240
+ if plan and not bodies and failures:
241
+ first = failures[0]
242
+ return failed(
243
+ first["code"],
244
+ query.target_id,
245
+ endpoint=first["endpointId"],
246
+ http_status=first["httpStatus"],
247
+ )
248
+ startup_body, startup_errors, startup_gaps = await _optional_startup(
249
+ services, query, snapshot, result
250
+ )
251
+ failures.extend(startup_errors)
252
+ compared = compare_drift(
253
+ query,
254
+ beans=bodies.get("beans"),
255
+ mappings_body=None if "mappings" not in bodies else bodies["mappings"].body,
256
+ flyway_body=None if "flyway" not in bodies else bodies["flyway"].body,
257
+ migration_observed="liquibase" in bodies,
258
+ env_body=_profile_evidence(bodies["env"].body, services)
259
+ if "env" in bodies
260
+ else None,
261
+ catalog=services.catalog,
262
+ )
263
+ missing = list(compared["missing"])
264
+ unexpected = list(compared["unexpected"])
265
+ _assign(missing, "m")
266
+ _assign(unexpected, "u")
267
+ started = _startup_start(startup_body)
268
+ not_restarted = bool(
269
+ query.source_modified_at
270
+ and started
271
+ and _later(query.source_modified_at, started)
272
+ )
273
+ if query.source_modified_at and startup_body is not None and started is None:
274
+ compared["gaps"] = [*compared["gaps"], "STARTUP_TIMING_UNOBSERVED"]
275
+ compared["incomplete"] = True
276
+ profile = compared["profile"]
277
+ result["data"] = {
278
+ "items": [*missing, *unexpected],
279
+ "profileMismatch": profile,
280
+ "rootCauseInferences": _inferences(
281
+ missing,
282
+ unexpected,
283
+ profile,
284
+ environment_override=bool(compared["environment_override"]),
285
+ not_restarted=not_restarted,
286
+ ),
287
+ }
288
+ result["errors"] = failures[:8]
289
+ gaps = (
290
+ list(compared["gaps"])
291
+ + [safe_code(item["code"]) for item in failures]
292
+ + startup_gaps
293
+ )
294
+ if startup_gaps:
295
+ compared["incomplete"] = True
296
+ if compared["incomplete"] and not gaps:
297
+ gaps.append("EVIDENCE_INCOMPLETE")
298
+ result["totalMatched"] = (
299
+ None
300
+ if compared["incomplete"] or failures or startup_gaps
301
+ else len(missing)
302
+ + len(unexpected)
303
+ + (
304
+ 1
305
+ if isinstance(profile, dict) and profile.get("state") == "mismatch"
306
+ else 0
307
+ )
308
+ )
309
+ partial(result, gaps)
310
+ return _project(finalize(result, services, query))
311
+ except NormalizationError as exc:
312
+ return failed(
313
+ exc.code,
314
+ request_target,
315
+ endpoint=exc.endpoint_id if isinstance(exc, EndpointReadError) else None,
316
+ http_status=exc.http_status if isinstance(exc, EndpointReadError) else None,
317
+ )
318
+ except TimeoutError:
319
+ return failed("TARGET_TIMEOUT", request_target)
320
+ except (ValueError, TypeError, KeyError, RecursionError, UnicodeError):
321
+ return failed("MALFORMED_EVIDENCE", request_target)
322
+ except Exception:
323
+ return failed("INTERNAL_ERROR", request_target)
@@ -0,0 +1,88 @@
1
+ """Read-only health handler for Boot 2.x details and 3.x/4.x components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from ..adapters.health import normalize_health
10
+ from ..contracts.definitions import validate_arguments
11
+ from ..models.common import NormalizationError
12
+ from ..models.queries import HealthQuery
13
+ from ..transport.response_gate import EndpointReadError
14
+ from ..services import Services
15
+ from ..diagnostics import failed, partial
16
+ from .support import tool_handler, finalize, read_checked, resume, shell, stamp
17
+
18
+ _ENDPOINTS = {
19
+ "application": "health",
20
+ "liveness": "health-liveness",
21
+ "readiness": "health-readiness",
22
+ }
23
+
24
+
25
+ @tool_handler
26
+ async def diagnose_health(
27
+ arguments: Mapping[str, Any], services: Services
28
+ ) -> dict[str, Any]:
29
+ request_target = "local"
30
+ try:
31
+ query = validate_arguments(arguments, "springboot_diagnose_health")
32
+ request_target = query.target_id
33
+ assert isinstance(query, HealthQuery)
34
+ if query.cursor:
35
+ return resume(services, query)
36
+ endpoint = _ENDPOINTS[query.group]
37
+ snapshot = uuid4().hex
38
+ observation = await read_checked(services, query.target_id, endpoint, snapshot)
39
+ overall, items, gaps = normalize_health(
40
+ observation.body,
41
+ component_path=query.component_path,
42
+ include_details=query.include_details,
43
+ include_healthy=query.include_healthy,
44
+ max_depth=query.max_depth,
45
+ )
46
+ result = shell(query.target_id, "registered target health", "health-1")
47
+ stamp(result, observation, endpoint, snapshot)
48
+ for item in items:
49
+ item["endpointId"] = endpoint
50
+ item["observationId"] = observation.observation_id
51
+ result["data"] = {
52
+ "overallStatus": overall,
53
+ "activeGroup": query.group,
54
+ "items": items,
55
+ }
56
+ limited = {"HEALTH_DETAILS_LIMIT", "HEALTH_DEPTH_LIMIT"} & set(gaps)
57
+ if limited:
58
+ result["truncated"] = True
59
+ result["truncationReasons"] = sorted(
60
+ set(result["truncationReasons"] + list(limited))
61
+ )[:8]
62
+ incomplete_count = {
63
+ "HEALTH_DETAILS_NOT_VISIBLE",
64
+ "HEALTH_COMPONENT_UNREADABLE",
65
+ "HEALTH_STATUS_UNOBSERVED",
66
+ } & set(gaps)
67
+ result["totalMatched"] = None if limited or incomplete_count else len(items)
68
+ partial(result, gaps)
69
+ if "HEALTH_DETAILS_NOT_VISIBLE" in result["coverage"]["gaps"]:
70
+ result["nextAction"] = {
71
+ "kind": "remediate",
72
+ "reasonCode": "HEALTH_DETAILS_NOT_VISIBLE",
73
+ "filterSuggestions": [],
74
+ }
75
+ return finalize(result, services, query)
76
+ except NormalizationError as exc:
77
+ return failed(
78
+ exc.code,
79
+ request_target,
80
+ endpoint=exc.endpoint_id if isinstance(exc, EndpointReadError) else None,
81
+ http_status=exc.http_status if isinstance(exc, EndpointReadError) else None,
82
+ )
83
+ except TimeoutError:
84
+ return failed("TARGET_TIMEOUT", request_target)
85
+ except (ValueError, TypeError, KeyError, RecursionError, UnicodeError):
86
+ return failed("MALFORMED_EVIDENCE", request_target)
87
+ except Exception:
88
+ return failed("INTERNAL_ERROR", request_target)