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,295 @@
1
+ """Normalize env precedence and configuration property bindings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from collections.abc import Iterator
7
+ from copy import deepcopy
8
+ from typing import Any
9
+
10
+ from ..models.common import AnalysisBudget
11
+
12
+ from ..contracts.definitions import glob_matches
13
+ from ..models.queries import ConfigQuery
14
+ from ..policy.sanitization import MARKER, SanitizerPipeline, sensitive_key
15
+ from ..policy.configuration import ConfigReadPolicy
16
+
17
+ _FILE = re.compile(r"class path resource \[([A-Za-z0-9_$./-]+)\](?: - (\d+):(\d+))?")
18
+ _MASK = re.compile(r"^\*+$")
19
+
20
+
21
+ def _kind(source_name: str) -> str:
22
+ lowered = source_name.casefold().replace("_", "")
23
+ if "applicationconfig" in lowered or "classpath" in lowered:
24
+ return "configurationFile"
25
+ if "systemenvironment" in lowered:
26
+ return "environmentVariable"
27
+ if "systemproperties" in lowered:
28
+ return "systemProperty"
29
+ if "commandline" in lowered:
30
+ return "commandLine"
31
+ return "other" if source_name else "unknown"
32
+
33
+
34
+ def _origin(raw: Any, source_name: str) -> dict[str, Any]:
35
+ kind = _kind(source_name)
36
+ file = line = column = None
37
+ if isinstance(raw, str):
38
+ matched = _FILE.search(raw)
39
+ if matched:
40
+ file = matched.group(1).rsplit("/", 1)[-1][:256]
41
+ line = int(matched.group(2)) if matched.group(2) else None
42
+ column = int(matched.group(3)) if matched.group(3) else None
43
+ kind = "configurationFile"
44
+ return {
45
+ "kind": kind,
46
+ "file": file,
47
+ "line": line,
48
+ "column": column,
49
+ "environmentVariable": None,
50
+ }
51
+
52
+
53
+ _NESTED_DEPTH = 4
54
+ _NESTED_LEAVES = 500
55
+
56
+
57
+ def _nested_leaves(
58
+ prefix: str,
59
+ node: dict[str, Any],
60
+ *,
61
+ gaps: set[str],
62
+ budget: list[int],
63
+ depth: int = 0,
64
+ ) -> Iterator[tuple[str, Any]]:
65
+ """Expand nested config objects to dotted leaves under fixed budgets."""
66
+ for leaf in sorted(node):
67
+ if not isinstance(leaf, str):
68
+ gaps.add("CONFIGPROPS_NESTED_UNSUPPORTED")
69
+ continue
70
+ key = f"{prefix}.{leaf}" if prefix else leaf
71
+ value = node[leaf]
72
+ if isinstance(value, dict):
73
+ if depth >= _NESTED_DEPTH:
74
+ gaps.add("CONFIGPROPS_NESTED_LIMIT")
75
+ continue
76
+ yield from _nested_leaves(
77
+ key, value, gaps=gaps, budget=budget, depth=depth + 1
78
+ )
79
+ continue
80
+ if isinstance(value, list):
81
+ gaps.add("CONFIGPROPS_NESTED_UNSUPPORTED")
82
+ continue
83
+ if budget[0] >= _NESTED_LEAVES:
84
+ gaps.add("CONFIGPROPS_NESTED_LIMIT")
85
+ continue
86
+ budget[0] += 1
87
+ yield key, value
88
+
89
+
90
+ def _selected(query: ConfigQuery, key: str) -> bool:
91
+ if query.prefix and key != query.prefix and not key.startswith(query.prefix + "."):
92
+ return False
93
+ return not (
94
+ query.property_key_pattern and not glob_matches(query.property_key_pattern, key)
95
+ )
96
+
97
+
98
+ def _project(
99
+ raw: Any, include: bool, key: str, pipeline: SanitizerPipeline
100
+ ) -> tuple[Any, str]:
101
+ if not include:
102
+ return None, "notReported"
103
+ if sensitive_key(key) or (
104
+ isinstance(raw, str) and (_MASK.fullmatch(raw) or pipeline.high_risk(raw))
105
+ ):
106
+ return MARKER, "redacted"
107
+ if isinstance(raw, str):
108
+ if any(char.isspace() for char in raw) or any(ord(char) < 32 for char in raw):
109
+ return MARKER, "redacted"
110
+ return raw[:512], "visible"
111
+ if isinstance(raw, bool) or type(raw) is int:
112
+ return raw, "visible"
113
+ if isinstance(raw, float) and raw == raw:
114
+ return raw, "visible"
115
+ return None, "notReported"
116
+
117
+
118
+ def normalize_config(
119
+ env: Any | None,
120
+ configprops: Any | None,
121
+ query: ConfigQuery,
122
+ pipeline: SanitizerPipeline,
123
+ *,
124
+ policy: ConfigReadPolicy | None = None,
125
+ ) -> tuple[list[str] | None, list[dict[str, Any]], list[str]]:
126
+ gaps: set[str] = set()
127
+ analysis_budget = AnalysisBudget()
128
+ profiles: list[str] | None = None
129
+ rows: list[dict[str, Any]] = []
130
+ if isinstance(env, dict):
131
+ raw_profiles = env.get("activeProfiles")
132
+ if isinstance(raw_profiles, list):
133
+ profiles = [
134
+ item
135
+ for item in raw_profiles
136
+ if isinstance(item, str)
137
+ and item
138
+ and not any(char.isspace() for char in item)
139
+ ][:20]
140
+ sources = env.get("propertySources")
141
+ if not isinstance(sources, list):
142
+ gaps.add("ENV_SOURCES_UNOBSERVED")
143
+ else:
144
+ seen: set[str] = set()
145
+ for source in sources:
146
+ if not isinstance(source, dict):
147
+ gaps.add("ENV_SOURCE_UNREADABLE")
148
+ continue
149
+ raw_name = source.get("name")
150
+ name = raw_name if isinstance(raw_name, str) else ""
151
+ properties = source.get("properties")
152
+ if not isinstance(properties, dict):
153
+ gaps.add("ENV_PROPERTIES_UNOBSERVED")
154
+ continue
155
+ for key in sorted(properties):
156
+ analysis_budget.consume()
157
+ if not isinstance(key, str) or not _selected(query, key):
158
+ continue
159
+ if policy is not None and not policy.allows(key):
160
+ gaps.add("CONFIG_SCOPE_LIMITED")
161
+ continue
162
+ if query.context_id is not None:
163
+ continue
164
+ spec = properties[key]
165
+ raw_value = spec.get("value") if isinstance(spec, dict) else spec
166
+ origin = spec.get("origin") if isinstance(spec, dict) else None
167
+ value, state = _project(
168
+ raw_value,
169
+ query.include_values
170
+ and (policy is None or policy.allow_values),
171
+ key,
172
+ pipeline,
173
+ )
174
+ rows.append(
175
+ {
176
+ "endpointId": "env",
177
+ "contextId": None,
178
+ "parentContextId": None,
179
+ "propertyKey": key[:256],
180
+ "prefix": key.rsplit(".", 1)[0] if "." in key else None,
181
+ "configurationBean": None,
182
+ "effectiveValue": value,
183
+ "valueState": state,
184
+ "bindingValue": None,
185
+ "bindingState": "notReported",
186
+ "origin": _origin(origin, name),
187
+ "sourceKind": _origin(origin, name)["kind"],
188
+ "effective": "no" if key in seen else "yes",
189
+ }
190
+ )
191
+ seen.add(key)
192
+ elif env is not None:
193
+ gaps.add("ENV_SOURCES_UNOBSERVED")
194
+ if isinstance(configprops, dict):
195
+ contexts = configprops.get("contexts")
196
+ if not isinstance(contexts, dict):
197
+ gaps.add("CONFIGPROPS_UNOBSERVED")
198
+ else:
199
+ for context_name in sorted(contexts):
200
+ if query.context_id and context_name != query.context_id:
201
+ continue
202
+ context = contexts[context_name]
203
+ beans = context.get("beans") if isinstance(context, dict) else None
204
+ if not isinstance(beans, dict):
205
+ gaps.add("CONFIGPROPS_UNOBSERVED")
206
+ continue
207
+ parent = (
208
+ context.get("parentId")
209
+ if isinstance(context.get("parentId"), str)
210
+ else None
211
+ )
212
+ for bean_name in sorted(beans):
213
+ bean = beans[bean_name]
214
+ if not isinstance(bean, dict):
215
+ continue
216
+ prefix = (
217
+ bean.get("prefix")
218
+ if isinstance(bean.get("prefix"), str)
219
+ else None
220
+ )
221
+ properties = bean.get("properties")
222
+ if not isinstance(properties, dict):
223
+ gaps.add("CONFIGPROPS_PROPERTIES_UNOBSERVED")
224
+ continue
225
+ budget = [0]
226
+ for key, raw_value in _nested_leaves(
227
+ prefix or "", properties, gaps=gaps, budget=budget
228
+ ):
229
+ analysis_budget.consume()
230
+ if not _selected(query, key):
231
+ continue
232
+ if policy is not None and not policy.allows(key):
233
+ gaps.add("CONFIG_SCOPE_LIMITED")
234
+ continue
235
+ value, state = _project(
236
+ raw_value,
237
+ query.include_values
238
+ and (policy is None or policy.allow_values),
239
+ key,
240
+ pipeline,
241
+ )
242
+ target = next(
243
+ (
244
+ row
245
+ for row in rows
246
+ if row["propertyKey"] == key
247
+ and row["effective"] == "yes"
248
+ ),
249
+ None,
250
+ )
251
+ if target is None:
252
+ rows.append(
253
+ {
254
+ "endpointId": "configprops",
255
+ "contextId": context_name
256
+ if isinstance(context_name, str)
257
+ else None,
258
+ "parentContextId": parent,
259
+ "propertyKey": key[:256],
260
+ "prefix": prefix,
261
+ "configurationBean": str(bean_name)[:256],
262
+ "effectiveValue": None,
263
+ "valueState": "notReported",
264
+ "bindingValue": value,
265
+ "bindingState": state,
266
+ "origin": {
267
+ "kind": "unknown",
268
+ "file": None,
269
+ "line": None,
270
+ "column": None,
271
+ "environmentVariable": None,
272
+ },
273
+ "sourceKind": "unknown",
274
+ "effective": "unknown",
275
+ }
276
+ )
277
+ else:
278
+ if target["configurationBean"] is not None:
279
+ target = deepcopy(target)
280
+ rows.append(target)
281
+ target["configurationBean"] = str(bean_name)[:256]
282
+ target["prefix"] = prefix or target["prefix"]
283
+ target["bindingValue"] = value
284
+ target["bindingState"] = state
285
+ target["contextId"] = (
286
+ context_name if isinstance(context_name, str) else None
287
+ )
288
+ target["parentContextId"] = parent
289
+ elif configprops is not None:
290
+ gaps.add("CONFIGPROPS_UNOBSERVED")
291
+ items = []
292
+ for index, row in enumerate(rows, start=1):
293
+ row["id"] = f"config_{index:06d}"
294
+ items.append(row)
295
+ return profiles if policy is None or policy.profiles else None, items, sorted(gaps)