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,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)