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.
- spring_runtime_mcp/__init__.py +3 -0
- spring_runtime_mcp/adapters/__init__.py +1 -0
- spring_runtime_mcp/adapters/beans.py +159 -0
- spring_runtime_mcp/adapters/conditions.py +333 -0
- spring_runtime_mcp/adapters/config.py +295 -0
- spring_runtime_mcp/adapters/drift.py +790 -0
- spring_runtime_mcp/adapters/health.py +164 -0
- spring_runtime_mcp/adapters/mapping_predicates.py +201 -0
- spring_runtime_mcp/adapters/mappings.py +294 -0
- spring_runtime_mcp/adapters/migrations.py +373 -0
- spring_runtime_mcp/adapters/openapi.py +683 -0
- spring_runtime_mcp/adapters/source_catalog.py +119 -0
- spring_runtime_mcp/adapters/startup.py +202 -0
- spring_runtime_mcp/analyzers/__init__.py +1 -0
- spring_runtime_mcp/analyzers/bean_graph.py +172 -0
- spring_runtime_mcp/analyzers/condition_failures.py +359 -0
- spring_runtime_mcp/cli.py +248 -0
- spring_runtime_mcp/contracts/__init__.py +1 -0
- spring_runtime_mcp/contracts/definitions.py +425 -0
- spring_runtime_mcp/diagnostics.py +254 -0
- spring_runtime_mcp/models/__init__.py +1 -0
- spring_runtime_mcp/models/bean_graph.py +77 -0
- spring_runtime_mcp/models/common.py +99 -0
- spring_runtime_mcp/models/conditions.py +56 -0
- spring_runtime_mcp/models/queries.py +140 -0
- spring_runtime_mcp/models/source_catalog.py +35 -0
- spring_runtime_mcp/models/timestamps.py +71 -0
- spring_runtime_mcp/policy/__init__.py +1 -0
- spring_runtime_mcp/policy/configuration.py +32 -0
- spring_runtime_mcp/policy/output_guard.py +117 -0
- spring_runtime_mcp/policy/sanitization.py +616 -0
- spring_runtime_mcp/py.typed +0 -0
- spring_runtime_mcp/replay.py +220 -0
- spring_runtime_mcp/replay_data/bean_graph/README.md +26 -0
- spring_runtime_mcp/replay_data/bean_graph/factory-method/conditions.json +43 -0
- spring_runtime_mcp/replay_data/bean_graph/factory-method/source-catalog.json +54 -0
- spring_runtime_mcp/replay_data/bean_graph/nested-conditions/beans.json +99 -0
- spring_runtime_mcp/replay_data/bean_graph/nested-conditions/conditions.json +25 -0
- spring_runtime_mcp/replay_data/bean_graph/parent-child/beans.json +92 -0
- spring_runtime_mcp/replay_data/bean_graph/shape-metadata.json +32 -0
- spring_runtime_mcp/replay_data/bean_graph/stripe-missing/conditions.json +43 -0
- spring_runtime_mcp/replay_data/bean_graph/stripe-missing/source-catalog.json +54 -0
- spring_runtime_mcp/replay_data/conformance/README.md +41 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v2.json +14 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-2.7-servlet/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-3.4-servlet/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/beans.json +92 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/conditions.json +52 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/configprops.json +20 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/env.json +16 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/flyway.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/health-v3.json +38 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/liquibase.json +30 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/mappings.json +40 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/openapi.json +54 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/servlet-namespace-comparison.json +4 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/source-catalog.json +90 -0
- spring_runtime_mcp/replay_data/conformance/boot-4.1-webflux/startup.json +45 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_analyze_startup.json +62 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_check_migrations.json +63 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_detect_drift.json +64 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_diagnose_health.json +59 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_beans.json +56 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_inspect_config.json +62 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_search_openapi.json +80 -0
- spring_runtime_mcp/replay_data/conformance/contracts/springboot_verify_mappings.json +58 -0
- spring_runtime_mcp/replay_data/conformance/faults/cases.json +63 -0
- spring_runtime_mcp/replay_data/conformance/faults/health-hidden.expected.json +54 -0
- spring_runtime_mcp/replay_data/conformance/faults/health-hidden.json +3 -0
- spring_runtime_mcp/replay_data/conformance/large/beans-2500.json +22506 -0
- spring_runtime_mcp/replay_data/conformance/manifest.json +46 -0
- spring_runtime_mcp/schemas/tools-contract.json +1902 -0
- spring_runtime_mcp/server.py +322 -0
- spring_runtime_mcp/services.py +44 -0
- spring_runtime_mcp/snapshots.py +340 -0
- spring_runtime_mcp/tools/__init__.py +1 -0
- spring_runtime_mcp/tools/analyze_startup.py +79 -0
- spring_runtime_mcp/tools/check_migrations.py +99 -0
- spring_runtime_mcp/tools/detect_drift.py +323 -0
- spring_runtime_mcp/tools/diagnose_health.py +88 -0
- spring_runtime_mcp/tools/inspect_beans.py +479 -0
- spring_runtime_mcp/tools/inspect_config.py +102 -0
- spring_runtime_mcp/tools/registry.py +48 -0
- spring_runtime_mcp/tools/search_openapi.py +79 -0
- spring_runtime_mcp/tools/support.py +166 -0
- spring_runtime_mcp/tools/verify_mappings.py +62 -0
- spring_runtime_mcp/transport/__init__.py +1 -0
- spring_runtime_mcp/transport/actuator_http.py +459 -0
- spring_runtime_mcp/transport/bounded_json.py +144 -0
- spring_runtime_mcp/transport/replay_files.py +71 -0
- spring_runtime_mcp/transport/response_gate.py +186 -0
- spring_runtime_mcp/transport/responses.py +82 -0
- spring_runtime_mcp/transport/stdio.py +44 -0
- spring_runtime_mcp-0.2.0.dist-info/METADATA +320 -0
- spring_runtime_mcp-0.2.0.dist-info/RECORD +118 -0
- spring_runtime_mcp-0.2.0.dist-info/WHEEL +4 -0
- spring_runtime_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- spring_runtime_mcp-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,479 @@
|
|
|
1
|
+
"""Read-only Bean handler, independent of HTTP and MCP host implementations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import time
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from typing import Any
|
|
10
|
+
from uuid import uuid4
|
|
11
|
+
|
|
12
|
+
from ..adapters.beans import normalize_beans
|
|
13
|
+
from ..adapters.conditions import normalize_conditions
|
|
14
|
+
from ..analyzers.bean_graph import build_dependency_graph, slice_dependencies
|
|
15
|
+
from ..analyzers.condition_failures import analyze_missing_beans, is_assignable
|
|
16
|
+
from ..contracts.definitions import glob_matches, validate_arguments
|
|
17
|
+
from ..diagnostics import _check, envelope, error, failed, partial, safe_code
|
|
18
|
+
from ..services import Services as Services
|
|
19
|
+
from ..snapshots import page_envelope
|
|
20
|
+
from .support import tool_handler
|
|
21
|
+
from ..models.common import (
|
|
22
|
+
AnalysisBudget,
|
|
23
|
+
AnalysisLimit,
|
|
24
|
+
NormalizationError,
|
|
25
|
+
)
|
|
26
|
+
from ..models.conditions import FailureAnalysis
|
|
27
|
+
from ..models.queries import InspectBeansQuery
|
|
28
|
+
from ..models.source_catalog import SourceCatalog
|
|
29
|
+
from ..policy.sanitization import MARKER, SanitizerPipeline
|
|
30
|
+
from ..transport.response_gate import EndpointReadError
|
|
31
|
+
from ..transport.responses import serialize_response
|
|
32
|
+
|
|
33
|
+
"""Fixture substitution is provenance, not missing Spring evidence.
|
|
34
|
+
|
|
35
|
+
An absent operator fact catalog explains unknown producers without marking the
|
|
36
|
+
runtime Bean evidence itself incomplete.
|
|
37
|
+
"""
|
|
38
|
+
_PROVENANCE_GAPS = frozenset({"FIXTURE_SUBSTITUTED", "SOURCE_CATALOG_UNPROVIDED"})
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _analysis_catalog(catalog: SourceCatalog) -> SourceCatalog:
|
|
42
|
+
kept = tuple(gap for gap in catalog.gaps if gap not in _PROVENANCE_GAPS)
|
|
43
|
+
if kept == catalog.gaps:
|
|
44
|
+
return catalog
|
|
45
|
+
return SourceCatalog(
|
|
46
|
+
catalog.target_id,
|
|
47
|
+
catalog.source_revision,
|
|
48
|
+
catalog.types,
|
|
49
|
+
catalog.producers,
|
|
50
|
+
kept,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _page(
|
|
55
|
+
result: dict[str, Any],
|
|
56
|
+
query: InspectBeansQuery,
|
|
57
|
+
services: Services,
|
|
58
|
+
*,
|
|
59
|
+
more: bool = False,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
items = result["data"]["items"]
|
|
62
|
+
count = min(len(items), query.max_results)
|
|
63
|
+
while True:
|
|
64
|
+
page = page_envelope(result, items[:count])
|
|
65
|
+
remaining = more or count < len(items)
|
|
66
|
+
credits = 50 - count
|
|
67
|
+
associations_cut = False
|
|
68
|
+
for item in page["data"]["items"]:
|
|
69
|
+
for field in ("aliases", "dependencies", "dependents"):
|
|
70
|
+
values = item[field]
|
|
71
|
+
keep = min(len(values), credits)
|
|
72
|
+
associations_cut |= keep < len(values)
|
|
73
|
+
item[field] = values[:keep]
|
|
74
|
+
credits -= keep
|
|
75
|
+
if associations_cut:
|
|
76
|
+
page["truncated"] = True
|
|
77
|
+
page["truncationReasons"] = sorted(
|
|
78
|
+
set(page["truncationReasons"] + ["ASSOCIATION_LIMIT"])
|
|
79
|
+
)[:8]
|
|
80
|
+
partial(page, ["ASSOCIATION_LIMIT"])
|
|
81
|
+
page["nextAction"]["filterSuggestions"] = [
|
|
82
|
+
{"parameter": "maxResults", "value": 1}
|
|
83
|
+
]
|
|
84
|
+
page["nextCursor"] = "x" * 64 if remaining else None
|
|
85
|
+
if remaining:
|
|
86
|
+
page["truncated"] = True
|
|
87
|
+
page["truncationReasons"] = sorted(
|
|
88
|
+
set(page["truncationReasons"] + ["MAX_RESULTS"])
|
|
89
|
+
)[:8]
|
|
90
|
+
page["nextAction"] = {
|
|
91
|
+
"kind": "page",
|
|
92
|
+
"reasonCode": "MORE_RESULTS",
|
|
93
|
+
"filterSuggestions": [],
|
|
94
|
+
}
|
|
95
|
+
page = services.sanitizer.sanitize(page)
|
|
96
|
+
try:
|
|
97
|
+
serialize_response(page, "\0" * 128, sanitizer=services.sanitizer)
|
|
98
|
+
break
|
|
99
|
+
except NormalizationError as exc:
|
|
100
|
+
if exc.code != "RESPONSE_TOO_LARGE":
|
|
101
|
+
raise
|
|
102
|
+
if count > 1:
|
|
103
|
+
count //= 2
|
|
104
|
+
continue
|
|
105
|
+
# Keep the root fact and explicitly discard oversized associations.
|
|
106
|
+
if count == 1 and any(
|
|
107
|
+
page["data"]["items"][0][k]
|
|
108
|
+
for k in ("dependencies", "dependents", "aliases")
|
|
109
|
+
):
|
|
110
|
+
for key in ("dependencies", "dependents", "aliases"):
|
|
111
|
+
items[0][key] = []
|
|
112
|
+
elif page["data"]["missingBeanAnalysis"]["conditions"]:
|
|
113
|
+
result["data"]["missingBeanAnalysis"] = {
|
|
114
|
+
"state": "partial",
|
|
115
|
+
"conditions": [],
|
|
116
|
+
"blockingPaths": [],
|
|
117
|
+
}
|
|
118
|
+
else:
|
|
119
|
+
return failed("RESPONSE_TOO_LARGE", query.target_id)
|
|
120
|
+
partial(result, ["RESPONSE_BUDGET"])
|
|
121
|
+
result["truncated"] = True
|
|
122
|
+
result["truncationReasons"] = ["RESPONSE_BUDGET"]
|
|
123
|
+
if remaining:
|
|
124
|
+
page["nextCursor"] = (
|
|
125
|
+
services.snapshots.advance(
|
|
126
|
+
query.cursor, query, count, sanitizer=services.sanitizer
|
|
127
|
+
)
|
|
128
|
+
if query.cursor
|
|
129
|
+
else services.snapshots.put_paged(
|
|
130
|
+
result, query, offset=count, sanitizer=services.sanitizer
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
serialize_response(page, "\0" * 128, sanitizer=services.sanitizer)
|
|
134
|
+
return page
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@tool_handler
|
|
138
|
+
async def inspect_beans(
|
|
139
|
+
arguments: Mapping[str, Any], services: Services
|
|
140
|
+
) -> dict[str, Any]:
|
|
141
|
+
"""Return a sanitized contract envelope; no caller-controlled network access."""
|
|
142
|
+
request_target = "local"
|
|
143
|
+
try:
|
|
144
|
+
query = validate_arguments(arguments)
|
|
145
|
+
request_target = query.target_id
|
|
146
|
+
if (
|
|
147
|
+
query.target_id not in services.targets
|
|
148
|
+
or services.catalog.target_id != query.target_id
|
|
149
|
+
):
|
|
150
|
+
raise NormalizationError("TARGET_NOT_REGISTERED")
|
|
151
|
+
if query.cursor:
|
|
152
|
+
stored, more = services.snapshots.get_page(
|
|
153
|
+
query.cursor, query, sanitizer=services.sanitizer
|
|
154
|
+
)
|
|
155
|
+
return _page(stored, query, services, more=more)
|
|
156
|
+
result = envelope(query.target_id)
|
|
157
|
+
snapshot = uuid4().hex
|
|
158
|
+
budget = AnalysisBudget(
|
|
159
|
+
deadline_monotonic=time.monotonic() + 15, monotonic=time.monotonic
|
|
160
|
+
)
|
|
161
|
+
observation = await asyncio.wait_for(
|
|
162
|
+
services.read(query.target_id, "beans", snapshot_id=snapshot),
|
|
163
|
+
timeout=15,
|
|
164
|
+
)
|
|
165
|
+
_check(observation, query.target_id, snapshot, "beans")
|
|
166
|
+
graph = build_dependency_graph(
|
|
167
|
+
normalize_beans(observation, budget=budget), budget=budget
|
|
168
|
+
)
|
|
169
|
+
result["source"].update(
|
|
170
|
+
snapshotId=snapshot,
|
|
171
|
+
endpointIds=["beans"],
|
|
172
|
+
observedAt=datetime.fromisoformat(
|
|
173
|
+
observation.observed_at.replace("Z", "+00:00")
|
|
174
|
+
)
|
|
175
|
+
.astimezone(UTC)
|
|
176
|
+
.isoformat()
|
|
177
|
+
.replace("+00:00", "Z"),
|
|
178
|
+
)
|
|
179
|
+
clean = services.sanitizer.identifier
|
|
180
|
+
provenance = [gap for gap in services.catalog.gaps if gap in _PROVENANCE_GAPS]
|
|
181
|
+
gaps = list(
|
|
182
|
+
dict.fromkeys(
|
|
183
|
+
(
|
|
184
|
+
*graph.gaps,
|
|
185
|
+
*(
|
|
186
|
+
gap
|
|
187
|
+
for gap in services.catalog.gaps
|
|
188
|
+
if gap not in _PROVENANCE_GAPS
|
|
189
|
+
),
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
)
|
|
193
|
+
if graph.coverage != "complete":
|
|
194
|
+
gaps.append("BEAN_GRAPH_INCOMPLETE")
|
|
195
|
+
items: list[dict[str, Any]] = []
|
|
196
|
+
graph_exhausted = False
|
|
197
|
+
association_credits = 50
|
|
198
|
+
for key, bean in sorted(graph.beans.items()):
|
|
199
|
+
# The bounded normalized index still contains valid scalar facts
|
|
200
|
+
# after traversal fuel runs out. Preserve them for filtering/paging.
|
|
201
|
+
if not graph_exhausted:
|
|
202
|
+
try:
|
|
203
|
+
budget.consume()
|
|
204
|
+
except AnalysisLimit as exc:
|
|
205
|
+
if exc.code != "GRAPH_LIMIT_REACHED":
|
|
206
|
+
raise
|
|
207
|
+
graph_exhausted = True
|
|
208
|
+
gaps.append("GRAPH_LIMIT_REACHED")
|
|
209
|
+
result["truncated"] = True
|
|
210
|
+
safe_names = tuple(
|
|
211
|
+
clean(name) for name in (key.canonical_name, *bean.aliases)
|
|
212
|
+
)
|
|
213
|
+
safe_context = clean(key.context.context_id)
|
|
214
|
+
safe_type = clean(bean.reported_type.value)
|
|
215
|
+
if MARKER in (*safe_names, safe_context, safe_type):
|
|
216
|
+
gaps.append("REDACTED_IDENTIFIERS")
|
|
217
|
+
if query.context_id and safe_context != query.context_id:
|
|
218
|
+
continue
|
|
219
|
+
if query.bean_name_pattern and not any(
|
|
220
|
+
isinstance(name, str)
|
|
221
|
+
and name != MARKER
|
|
222
|
+
and glob_matches(query.bean_name_pattern, name)
|
|
223
|
+
for name in safe_names
|
|
224
|
+
):
|
|
225
|
+
continue
|
|
226
|
+
if bean.reported_type.state != "known":
|
|
227
|
+
gaps.append("TYPE_NOT_REPORTED")
|
|
228
|
+
if bean.scope.state != "known":
|
|
229
|
+
gaps.append("SCOPE_NOT_REPORTED")
|
|
230
|
+
# resource is optional for programmatic beans; a null value is a
|
|
231
|
+
# reported fact, not missing coverage.
|
|
232
|
+
actual = bean.reported_type.value if safe_type != MARKER else None
|
|
233
|
+
if query.package_prefix and not (
|
|
234
|
+
actual and (actual.startswith(query.package_prefix.rstrip(".") + "."))
|
|
235
|
+
):
|
|
236
|
+
continue
|
|
237
|
+
if query.type_pattern and not (
|
|
238
|
+
actual and glob_matches(query.type_pattern, actual)
|
|
239
|
+
):
|
|
240
|
+
if not actual or "*" in query.type_pattern or "?" in query.type_pattern:
|
|
241
|
+
continue
|
|
242
|
+
try:
|
|
243
|
+
assignable = is_assignable(
|
|
244
|
+
actual, query.type_pattern, services.catalog, budget=budget
|
|
245
|
+
)
|
|
246
|
+
except AnalysisLimit as exc:
|
|
247
|
+
if exc.code != "GRAPH_LIMIT_REACHED":
|
|
248
|
+
raise
|
|
249
|
+
graph_exhausted = True
|
|
250
|
+
gaps.extend(["GRAPH_LIMIT_REACHED", "SOURCE_TYPE_UNOBSERVED"])
|
|
251
|
+
result["truncated"] = True
|
|
252
|
+
continue
|
|
253
|
+
if assignable.state != "known":
|
|
254
|
+
gaps.append("SOURCE_TYPE_UNOBSERVED")
|
|
255
|
+
if assignable.value is not True:
|
|
256
|
+
continue
|
|
257
|
+
context = graph.contexts[key.context]
|
|
258
|
+
item: dict[str, Any] = {
|
|
259
|
+
"id": bean.id,
|
|
260
|
+
"endpointId": "beans",
|
|
261
|
+
"observationId": clean(observation.observation_id, max_length=96),
|
|
262
|
+
"contextId": clean(key.context.context_id),
|
|
263
|
+
"parentContextId": clean(context.parent.value.context_id)
|
|
264
|
+
if context.parent.value
|
|
265
|
+
else None,
|
|
266
|
+
"name": clean(key.canonical_name),
|
|
267
|
+
"type": clean(actual),
|
|
268
|
+
"scope": clean(bean.scope.value, max_length=64),
|
|
269
|
+
"resource": services.sanitizer.resource(bean.resource.value),
|
|
270
|
+
"aliases": [
|
|
271
|
+
clean(a) for a in bean.aliases[: min(16, association_credits)]
|
|
272
|
+
],
|
|
273
|
+
"producer": None,
|
|
274
|
+
"dependencies": [],
|
|
275
|
+
"dependents": [],
|
|
276
|
+
}
|
|
277
|
+
association_credits -= len(item["aliases"])
|
|
278
|
+
if len(bean.aliases) > len(item["aliases"]):
|
|
279
|
+
gaps.append("ASSOCIATION_LIMIT")
|
|
280
|
+
result["truncated"] = True
|
|
281
|
+
producers = [
|
|
282
|
+
p
|
|
283
|
+
for p in services.catalog.producers
|
|
284
|
+
if p.context_id == key.context.context_id
|
|
285
|
+
and (
|
|
286
|
+
p.bean_name == key.canonical_name or key.canonical_name in p.aliases
|
|
287
|
+
)
|
|
288
|
+
and actual in (p.declared_type, p.implementation_type)
|
|
289
|
+
]
|
|
290
|
+
if len(producers) == 1:
|
|
291
|
+
producer = producers[0]
|
|
292
|
+
item["producer"] = {
|
|
293
|
+
"kind": "factoryMethod"
|
|
294
|
+
if producer.factory_method
|
|
295
|
+
else "configuration",
|
|
296
|
+
"configurationClass": clean(producer.configuration_class),
|
|
297
|
+
"methodName": clean(producer.factory_method),
|
|
298
|
+
"evidenceLevel": "correlated",
|
|
299
|
+
}
|
|
300
|
+
for direction in ("dependencies", "dependents"):
|
|
301
|
+
if query.direction not in (direction, "both"):
|
|
302
|
+
continue
|
|
303
|
+
if graph_exhausted:
|
|
304
|
+
continue
|
|
305
|
+
if association_credits == 0:
|
|
306
|
+
index = (
|
|
307
|
+
graph.forward if direction == "dependencies" else graph.reverse
|
|
308
|
+
)
|
|
309
|
+
if index.get(key):
|
|
310
|
+
gaps.append("ASSOCIATION_LIMIT")
|
|
311
|
+
result["truncated"] = True
|
|
312
|
+
continue
|
|
313
|
+
sliced = slice_dependencies(
|
|
314
|
+
graph,
|
|
315
|
+
(key,),
|
|
316
|
+
direction=direction,
|
|
317
|
+
max_depth=query.max_depth,
|
|
318
|
+
budget=budget,
|
|
319
|
+
)
|
|
320
|
+
gaps.extend(sliced.gaps)
|
|
321
|
+
kept_edges = min(16, association_credits)
|
|
322
|
+
if sliced.truncated or len(sliced.edges) > kept_edges:
|
|
323
|
+
gaps.append("ASSOCIATION_LIMIT")
|
|
324
|
+
result["truncated"] = True
|
|
325
|
+
association_credits -= min(len(sliced.edges), kept_edges)
|
|
326
|
+
for edge, depth in sliced.edges[:kept_edges]:
|
|
327
|
+
target = (
|
|
328
|
+
edge.resolution.candidates[0]
|
|
329
|
+
if edge.resolution.state == "resolved"
|
|
330
|
+
else None
|
|
331
|
+
)
|
|
332
|
+
item[direction].append(
|
|
333
|
+
{
|
|
334
|
+
"fromId": graph.beans[edge.from_key].id,
|
|
335
|
+
"toId": graph.beans[target].id if target else None,
|
|
336
|
+
"requestedName": clean(edge.requested_name),
|
|
337
|
+
"targetContextId": clean(target.context.context_id)
|
|
338
|
+
if target
|
|
339
|
+
else None,
|
|
340
|
+
"targetName": clean(target.canonical_name)
|
|
341
|
+
if target
|
|
342
|
+
else None,
|
|
343
|
+
"resolution": edge.resolution.state,
|
|
344
|
+
"depth": depth,
|
|
345
|
+
}
|
|
346
|
+
)
|
|
347
|
+
items.append(item)
|
|
348
|
+
result["data"]["items"] = items
|
|
349
|
+
if not items and any(
|
|
350
|
+
(query.bean_name_pattern, query.type_pattern, query.package_prefix)
|
|
351
|
+
):
|
|
352
|
+
try:
|
|
353
|
+
conditions = await asyncio.wait_for(
|
|
354
|
+
services.read(query.target_id, "conditions", snapshot_id=snapshot),
|
|
355
|
+
timeout=15,
|
|
356
|
+
)
|
|
357
|
+
_check(conditions, query.target_id, snapshot, "conditions")
|
|
358
|
+
result["source"]["endpointIds"].append("conditions")
|
|
359
|
+
assert conditions.body is not None
|
|
360
|
+
contexts = conditions.body.get("contexts", {})
|
|
361
|
+
if not isinstance(contexts, dict):
|
|
362
|
+
raise NormalizationError("CONTEXT_HIERARCHY_CONFLICT")
|
|
363
|
+
for context_key, context in graph.contexts.items():
|
|
364
|
+
raw = contexts.get(context_key.context_id)
|
|
365
|
+
expected = (
|
|
366
|
+
context.parent.value.context_id
|
|
367
|
+
if context.parent.value
|
|
368
|
+
else None
|
|
369
|
+
)
|
|
370
|
+
if not isinstance(raw, dict) or raw.get("parentId") != expected:
|
|
371
|
+
raise NormalizationError("CONTEXT_HIERARCHY_CONFLICT")
|
|
372
|
+
report = normalize_conditions(conditions, budget=budget)
|
|
373
|
+
analysis = analyze_missing_beans(
|
|
374
|
+
query,
|
|
375
|
+
graph,
|
|
376
|
+
report,
|
|
377
|
+
_analysis_catalog(services.catalog),
|
|
378
|
+
budget=budget,
|
|
379
|
+
)
|
|
380
|
+
result["data"]["missingBeanAnalysis"] = _failure(
|
|
381
|
+
analysis, services.sanitizer
|
|
382
|
+
)
|
|
383
|
+
gaps.extend(analysis.gaps)
|
|
384
|
+
if analysis.state in ("partial", "unknown"):
|
|
385
|
+
gaps.append("CONDITION_EVIDENCE_INCOMPLETE")
|
|
386
|
+
if analysis.truncated or len(analysis.conditions) > 16:
|
|
387
|
+
result["truncated"] = True
|
|
388
|
+
gaps.append("CONDITION_LIMIT")
|
|
389
|
+
except NormalizationError as exc:
|
|
390
|
+
result["data"]["missingBeanAnalysis"]["state"] = "unknown"
|
|
391
|
+
gaps.append(safe_code(exc.code))
|
|
392
|
+
result["errors"].append(
|
|
393
|
+
error(
|
|
394
|
+
exc.code,
|
|
395
|
+
"conditions",
|
|
396
|
+
exc.http_status if isinstance(exc, EndpointReadError) else None,
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
incomplete_evidence = {
|
|
400
|
+
"SOURCE_TYPE_UNOBSERVED",
|
|
401
|
+
"REDACTED_IDENTIFIERS",
|
|
402
|
+
"TYPE_NOT_REPORTED",
|
|
403
|
+
"SCOPE_NOT_REPORTED",
|
|
404
|
+
}
|
|
405
|
+
result["totalMatched"] = None if incomplete_evidence & set(gaps) else len(items)
|
|
406
|
+
partial(result, gaps)
|
|
407
|
+
if provenance:
|
|
408
|
+
result["coverage"]["gaps"] = sorted(
|
|
409
|
+
set(result["coverage"]["gaps"] + provenance)
|
|
410
|
+
)[:10]
|
|
411
|
+
if result["truncated"]:
|
|
412
|
+
cause_order = (
|
|
413
|
+
"RESPONSE_BUDGET",
|
|
414
|
+
"GRAPH_LIMIT_REACHED",
|
|
415
|
+
"CONDITION_LIMIT",
|
|
416
|
+
"ASSOCIATION_LIMIT",
|
|
417
|
+
"MAX_RESULTS",
|
|
418
|
+
)
|
|
419
|
+
derived = [
|
|
420
|
+
code
|
|
421
|
+
for code in cause_order
|
|
422
|
+
if code in gaps or code in result["truncationReasons"]
|
|
423
|
+
][:8]
|
|
424
|
+
if not derived:
|
|
425
|
+
derived = list(result["truncationReasons"]) or ["RESPONSE_BUDGET"]
|
|
426
|
+
result["truncationReasons"] = derived
|
|
427
|
+
return _page(services.sanitizer.sanitize(result), query, services)
|
|
428
|
+
except NormalizationError as exc:
|
|
429
|
+
return failed(
|
|
430
|
+
exc.code,
|
|
431
|
+
request_target,
|
|
432
|
+
endpoint=exc.endpoint_id if isinstance(exc, EndpointReadError) else None,
|
|
433
|
+
http_status=exc.http_status if isinstance(exc, EndpointReadError) else None,
|
|
434
|
+
)
|
|
435
|
+
except TimeoutError:
|
|
436
|
+
return failed("TARGET_TIMEOUT", request_target)
|
|
437
|
+
except (ValueError, TypeError, KeyError, RecursionError, UnicodeError):
|
|
438
|
+
return failed("MALFORMED_EVIDENCE", request_target)
|
|
439
|
+
except Exception:
|
|
440
|
+
return failed("INTERNAL_ERROR", request_target)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _failure(analysis: FailureAnalysis, sanitizer: SanitizerPipeline) -> dict[str, Any]:
|
|
444
|
+
retained = {node.id for node in analysis.conditions[:16]}
|
|
445
|
+
nodes = []
|
|
446
|
+
for node in analysis.conditions[:16]:
|
|
447
|
+
children = list(
|
|
448
|
+
dict.fromkeys(
|
|
449
|
+
(
|
|
450
|
+
*node.child_ids,
|
|
451
|
+
*(e.to_id for e in analysis.blocking_edges if e.from_id == node.id),
|
|
452
|
+
)
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
nodes.append(
|
|
456
|
+
{
|
|
457
|
+
"id": node.id,
|
|
458
|
+
"configurationClass": sanitizer.identifier(node.source.split("#")[0]),
|
|
459
|
+
"conditionType": sanitizer.identifier(node.condition_type),
|
|
460
|
+
"operator": node.operator,
|
|
461
|
+
"outcome": node.derived_outcome,
|
|
462
|
+
"reasonCode": node.reason_code,
|
|
463
|
+
"childIds": [c for c in children if c in retained][:16],
|
|
464
|
+
"requiredBeanIds": [],
|
|
465
|
+
"childrenComplete": node.children_complete
|
|
466
|
+
and all(c in retained for c in children)
|
|
467
|
+
and len(children) <= 16,
|
|
468
|
+
}
|
|
469
|
+
)
|
|
470
|
+
cut = len(analysis.conditions) > 16
|
|
471
|
+
return {
|
|
472
|
+
"state": "partial" if cut else analysis.state,
|
|
473
|
+
"conditions": nodes,
|
|
474
|
+
"blockingPaths": [
|
|
475
|
+
list(p)
|
|
476
|
+
for p in analysis.blocking_paths
|
|
477
|
+
if len(p) <= 16 and all(c in retained for c in p)
|
|
478
|
+
][:8],
|
|
479
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Read-only env and configuration-properties 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.config import normalize_config
|
|
10
|
+
from ..contracts.definitions import validate_arguments
|
|
11
|
+
from ..models.common import NormalizationError
|
|
12
|
+
from ..models.queries import ConfigQuery
|
|
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 inspect_config(
|
|
21
|
+
arguments: Mapping[str, Any], services: Services
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
request_target = "local"
|
|
24
|
+
try:
|
|
25
|
+
query = validate_arguments(arguments, "springboot_inspect_config")
|
|
26
|
+
request_target = query.target_id
|
|
27
|
+
assert isinstance(query, ConfigQuery)
|
|
28
|
+
if services.config_policy is None or not (
|
|
29
|
+
services.config_policy.properties or services.config_policy.prefixes
|
|
30
|
+
):
|
|
31
|
+
raise NormalizationError("CAPABILITY_DISABLED")
|
|
32
|
+
if query.cursor:
|
|
33
|
+
return resume(services, query)
|
|
34
|
+
if (
|
|
35
|
+
query.target_id not in services.targets
|
|
36
|
+
or services.catalog.target_id != query.target_id
|
|
37
|
+
):
|
|
38
|
+
raise NormalizationError("TARGET_NOT_REGISTERED")
|
|
39
|
+
snapshot = uuid4().hex
|
|
40
|
+
result = shell(query.target_id, "registered target config", "config-1")
|
|
41
|
+
bodies: dict[str, Any] = {}
|
|
42
|
+
observation_ids: dict[str, str] = {}
|
|
43
|
+
failures: list[dict[str, Any]] = []
|
|
44
|
+
for endpoint in ("env", "configprops"):
|
|
45
|
+
try:
|
|
46
|
+
observation = await read_checked(
|
|
47
|
+
services, query.target_id, endpoint, snapshot
|
|
48
|
+
)
|
|
49
|
+
except NormalizationError as exc:
|
|
50
|
+
failures.append(
|
|
51
|
+
error(
|
|
52
|
+
exc.code,
|
|
53
|
+
endpoint,
|
|
54
|
+
exc.http_status if isinstance(exc, EndpointReadError) else None,
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
continue
|
|
58
|
+
bodies[endpoint] = observation.body
|
|
59
|
+
observation_ids[endpoint] = observation.observation_id
|
|
60
|
+
stamp(result, observation, endpoint, snapshot)
|
|
61
|
+
if not bodies:
|
|
62
|
+
first = failures[0]
|
|
63
|
+
return failed(
|
|
64
|
+
first["code"],
|
|
65
|
+
query.target_id,
|
|
66
|
+
endpoint=first["endpointId"],
|
|
67
|
+
http_status=first["httpStatus"],
|
|
68
|
+
)
|
|
69
|
+
profiles, items, gaps = normalize_config(
|
|
70
|
+
bodies.get("env"),
|
|
71
|
+
bodies.get("configprops"),
|
|
72
|
+
query,
|
|
73
|
+
services.sanitizer,
|
|
74
|
+
policy=services.config_policy,
|
|
75
|
+
)
|
|
76
|
+
for item in items:
|
|
77
|
+
item["observationId"] = observation_ids.get(
|
|
78
|
+
item["endpointId"], "observation_config"
|
|
79
|
+
)
|
|
80
|
+
result["data"] = {"activeProfiles": profiles, "items": items}
|
|
81
|
+
result["totalMatched"] = None if "CONFIG_SCOPE_LIMITED" in gaps else len(items)
|
|
82
|
+
result["errors"] = failures
|
|
83
|
+
if "CONFIGPROPS_NESTED_LIMIT" in gaps:
|
|
84
|
+
result["truncated"] = True
|
|
85
|
+
result["truncationReasons"] = sorted(
|
|
86
|
+
set(result["truncationReasons"] + ["CONFIGPROPS_NESTED_LIMIT"])
|
|
87
|
+
)[:8]
|
|
88
|
+
partial(result, [safe_code(item["code"]) for item in failures] + gaps)
|
|
89
|
+
return finalize(result, services, query)
|
|
90
|
+
except NormalizationError as exc:
|
|
91
|
+
return failed(
|
|
92
|
+
exc.code,
|
|
93
|
+
request_target,
|
|
94
|
+
endpoint=exc.endpoint_id if isinstance(exc, EndpointReadError) else None,
|
|
95
|
+
http_status=exc.http_status if isinstance(exc, EndpointReadError) else None,
|
|
96
|
+
)
|
|
97
|
+
except TimeoutError:
|
|
98
|
+
return failed("TARGET_TIMEOUT", request_target)
|
|
99
|
+
except (ValueError, TypeError, KeyError, RecursionError, UnicodeError):
|
|
100
|
+
return failed("MALFORMED_EVIDENCE", request_target)
|
|
101
|
+
except Exception:
|
|
102
|
+
return failed("INTERNAL_ERROR", request_target)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Complete diagnostic registry, checked before the host starts serving."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from ..contracts.definitions import list_tools
|
|
9
|
+
from .analyze_startup import analyze_startup
|
|
10
|
+
from .check_migrations import check_migrations
|
|
11
|
+
from .detect_drift import detect_drift
|
|
12
|
+
from .diagnose_health import diagnose_health
|
|
13
|
+
from ..services import Services
|
|
14
|
+
from .inspect_beans import inspect_beans
|
|
15
|
+
from ..diagnostics import failed
|
|
16
|
+
from .inspect_config import inspect_config
|
|
17
|
+
from .search_openapi import search_openapi
|
|
18
|
+
from .verify_mappings import verify_mappings
|
|
19
|
+
|
|
20
|
+
Handler = Callable[[Mapping[str, Any], Services], Awaitable[dict[str, Any]]]
|
|
21
|
+
|
|
22
|
+
HANDLERS: dict[str, Handler] = {
|
|
23
|
+
"springboot_analyze_startup": analyze_startup,
|
|
24
|
+
"springboot_check_migrations": check_migrations,
|
|
25
|
+
"springboot_detect_drift": detect_drift,
|
|
26
|
+
"springboot_diagnose_health": diagnose_health,
|
|
27
|
+
"springboot_inspect_beans": inspect_beans,
|
|
28
|
+
"springboot_inspect_config": inspect_config,
|
|
29
|
+
"springboot_search_openapi": search_openapi,
|
|
30
|
+
"springboot_verify_mappings": verify_mappings,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def validate_handlers() -> None:
|
|
35
|
+
if set(HANDLERS) != {item["name"] for item in list_tools()}:
|
|
36
|
+
raise RuntimeError("INVALID_TOOL_HANDLER_REGISTRATION")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def call_tool(
|
|
40
|
+
name: str, arguments: Mapping[str, Any], services: Services
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
handler = HANDLERS.get(name)
|
|
43
|
+
if handler is None:
|
|
44
|
+
known = {item["name"] for item in list_tools()}
|
|
45
|
+
if name in known:
|
|
46
|
+
raise RuntimeError("INVALID_TOOL_HANDLER_REGISTRATION")
|
|
47
|
+
return failed("UNKNOWN_TOOL")
|
|
48
|
+
return await handler(arguments, services)
|