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,425 @@
1
+ """Load the committed contract without introducing a runtime validator dependency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import re
8
+ from collections.abc import Mapping
9
+ from copy import deepcopy
10
+ from functools import lru_cache
11
+ from importlib.resources import files
12
+ from typing import Any, Literal, cast, overload
13
+
14
+ from ..models.common import NormalizationError
15
+ from ..models.timestamps import parse_rfc3339
16
+ from ..models.queries import (
17
+ ConfigQuery,
18
+ DriftQuery,
19
+ ExpectedBean,
20
+ ExpectedMigration,
21
+ ExpectedMigrationVersion,
22
+ ExpectedRoute,
23
+ HealthQuery,
24
+ InspectBeansQuery,
25
+ MappingQuery,
26
+ MigrationQuery,
27
+ OpenApiQuery,
28
+ StartupQuery,
29
+ )
30
+
31
+ TOOL = "springboot_inspect_beans"
32
+
33
+
34
+ @lru_cache(maxsize=1)
35
+ def _definitions() -> tuple[dict[str, Any], ...]:
36
+ resource = files("spring_runtime_mcp").joinpath("schemas/tools-contract.json")
37
+ bundle = json.loads(resource.read_text(encoding="utf-8"))
38
+ definitions = tuple(bundle["tools"])
39
+ if len(definitions) != 8 or [d["name"] for d in definitions] != sorted(
40
+ d["name"] for d in definitions
41
+ ):
42
+ raise RuntimeError("INVALID_TOOL_REGISTRATION_ORDER")
43
+ return definitions
44
+
45
+
46
+ def list_tools() -> list[dict[str, Any]]:
47
+ """Return defensive copies so a caller cannot mutate future tool listings."""
48
+ return deepcopy(list(_definitions()))
49
+
50
+
51
+ def definition(name: str) -> dict[str, Any]:
52
+ for item in _definitions():
53
+ if item["name"] == name:
54
+ return deepcopy(item)
55
+ raise NormalizationError("UNKNOWN_TOOL")
56
+
57
+
58
+ def _matches(schema: Mapping[str, Any], value: Any) -> bool:
59
+ """Validate the bounded subset used by this tool's committed input schema.
60
+
61
+ Unknown validation keywords fail closed. This is not a general JSON Schema
62
+ implementation; conformance tests compare it with Draft202012Validator.
63
+ """
64
+ known = {
65
+ "$schema",
66
+ "type",
67
+ "properties",
68
+ "required",
69
+ "additionalProperties",
70
+ "allOf",
71
+ "anyOf",
72
+ "enum",
73
+ "minimum",
74
+ "maximum",
75
+ "minLength",
76
+ "maxLength",
77
+ "pattern",
78
+ "default",
79
+ "description",
80
+ "items",
81
+ "maxItems",
82
+ "minItems",
83
+ "uniqueItems",
84
+ "const",
85
+ "oneOf",
86
+ "not",
87
+ "format",
88
+ }
89
+ if set(schema) - known:
90
+ raise RuntimeError("UNSUPPORTED_INPUT_SCHEMA_KEYWORD")
91
+ kind = schema.get("type")
92
+ if kind == "object":
93
+ if not isinstance(value, dict):
94
+ return False
95
+ props = schema.get("properties", {})
96
+ if schema.get("additionalProperties") is False and set(value) - set(props):
97
+ return False
98
+ if any(key not in value for key in schema.get("required", [])):
99
+ return False
100
+ if any(not _matches(props[key], v) for key, v in value.items() if key in props):
101
+ return False
102
+ elif kind == "string":
103
+ if not isinstance(value, str):
104
+ return False
105
+ if not schema.get("minLength", 0) <= len(value) <= schema.get("maxLength", 256):
106
+ return False
107
+ if "pattern" in schema and re.search(schema["pattern"], value) is None:
108
+ return False
109
+ if schema.get("format") == "date-time":
110
+ if not _rfc3339(value):
111
+ return False
112
+ elif "format" in schema:
113
+ return False
114
+ elif kind == "integer":
115
+ if type(value) is not int:
116
+ return False
117
+ if not schema.get("minimum", value) <= value <= schema.get("maximum", value):
118
+ return False
119
+ elif kind == "number":
120
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
121
+ return False
122
+ if isinstance(value, float) and not math.isfinite(value):
123
+ return False
124
+ if not schema.get("minimum", value) <= value <= schema.get("maximum", value):
125
+ return False
126
+ elif kind == "boolean":
127
+ if type(value) is not bool:
128
+ return False
129
+ elif kind == "array":
130
+ if not isinstance(value, list):
131
+ return False
132
+ if len(value) > schema.get("maxItems", 200):
133
+ return False
134
+ if len(value) < schema.get("minItems", 0):
135
+ return False
136
+ if schema.get("uniqueItems"):
137
+ try:
138
+ fingerprints = [
139
+ json.dumps(item, sort_keys=True, ensure_ascii=False)
140
+ for item in value
141
+ ]
142
+ except (TypeError, ValueError):
143
+ return False
144
+ if len(fingerprints) != len(set(fingerprints)):
145
+ return False
146
+ item_schema = schema.get("items")
147
+ if isinstance(item_schema, dict) and any(
148
+ not _matches(item_schema, item) for item in value
149
+ ):
150
+ return False
151
+ elif kind is not None:
152
+ raise RuntimeError("UNSUPPORTED_INPUT_SCHEMA_TYPE")
153
+ # Subschemas containing only required are valid on object instances.
154
+ if (
155
+ "required" in schema
156
+ and isinstance(value, dict)
157
+ and any(key not in value for key in schema["required"])
158
+ ):
159
+ return False
160
+ if "enum" in schema and value not in schema["enum"]:
161
+ return False
162
+ if "allOf" in schema and not all(_matches(s, value) for s in schema["allOf"]):
163
+ return False
164
+ if "anyOf" in schema and not any(_matches(s, value) for s in schema["anyOf"]):
165
+ return False
166
+ if "not" in schema and _matches(schema["not"], value):
167
+ return False
168
+ if "oneOf" in schema:
169
+ matched = 0
170
+ for sub in schema["oneOf"]:
171
+ if _matches(sub, value):
172
+ matched += 1
173
+ if matched > 1:
174
+ return False
175
+ if matched != 1:
176
+ return False
177
+ return True
178
+
179
+
180
+ def _bounded_argument(value: Any, depth: int = 0) -> None:
181
+ if depth > 8:
182
+ raise NormalizationError("INVALID_ARGUMENT")
183
+ if isinstance(value, str):
184
+ if len(value) > 2048:
185
+ raise NormalizationError("INVALID_ARGUMENT")
186
+ return
187
+ if value is None or type(value) is bool:
188
+ return
189
+ if type(value) is int:
190
+ if value.bit_length() > 64:
191
+ raise NormalizationError("INVALID_ARGUMENT")
192
+ return
193
+ if isinstance(value, float):
194
+ if not math.isfinite(value):
195
+ raise NormalizationError("INVALID_ARGUMENT")
196
+ return
197
+ if isinstance(value, list):
198
+ if len(value) > 200:
199
+ raise NormalizationError("INVALID_ARGUMENT")
200
+ for item in value:
201
+ _bounded_argument(item, depth + 1)
202
+ return
203
+ if isinstance(value, dict):
204
+ if len(value) > 32 or any(not isinstance(key, str) for key in value):
205
+ raise NormalizationError("INVALID_ARGUMENT")
206
+ for item in value.values():
207
+ _bounded_argument(item, depth + 1)
208
+ return
209
+ raise NormalizationError("INVALID_ARGUMENT")
210
+
211
+
212
+ def _rfc3339(value: str) -> bool:
213
+ return parse_rfc3339(value) is not None
214
+
215
+
216
+ def _expected_beans(items: list[dict[str, Any]]) -> tuple[ExpectedBean, ...]:
217
+ return tuple(
218
+ ExpectedBean(
219
+ name=item.get("name"),
220
+ type_name=item.get("type"),
221
+ context_id=item.get("contextId"),
222
+ aliases=tuple(item.get("aliases") or ()),
223
+ )
224
+ for item in items
225
+ )
226
+
227
+
228
+ def _expected_routes(items: list[dict[str, Any]]) -> tuple[ExpectedRoute, ...]:
229
+ return tuple(
230
+ ExpectedRoute(
231
+ path=item["path"],
232
+ http_method=item["httpMethod"],
233
+ controller_class=item.get("controllerClass"),
234
+ context_id=item.get("contextId"),
235
+ )
236
+ for item in items
237
+ )
238
+
239
+
240
+ @overload
241
+ def validate_arguments(
242
+ arguments: Mapping[str, Any],
243
+ tool_name: Literal["springboot_inspect_beans"] = "springboot_inspect_beans",
244
+ ) -> InspectBeansQuery: ...
245
+
246
+
247
+ @overload
248
+ def validate_arguments(arguments: Mapping[str, Any], tool_name: str) -> Any: ...
249
+
250
+
251
+ def validate_arguments(
252
+ arguments: Mapping[str, Any],
253
+ tool_name: str = TOOL,
254
+ ) -> Any:
255
+ if not isinstance(arguments, dict):
256
+ raise NormalizationError("INVALID_ARGUMENT")
257
+ # Bound before serialization, including values that are invalid for the schema.
258
+ if len(arguments) > 12 or any(not isinstance(k, str) for k in arguments):
259
+ raise NormalizationError("INVALID_ARGUMENT")
260
+ for value in arguments.values():
261
+ _bounded_argument(value)
262
+ try:
263
+ encoded_size = len(
264
+ json.dumps(arguments, ensure_ascii=False, allow_nan=False).encode("utf-8")
265
+ )
266
+ except (UnicodeError, ValueError, OverflowError):
267
+ raise NormalizationError("INVALID_ARGUMENT") from None
268
+ if encoded_size > 16_384:
269
+ raise NormalizationError("INVALID_ARGUMENT")
270
+ schema = definition(tool_name)["inputSchema"]
271
+ if not _matches(schema, arguments):
272
+ raise NormalizationError("INVALID_ARGUMENT")
273
+ values = {
274
+ key: deepcopy(item["default"])
275
+ for key, item in schema["properties"].items()
276
+ if "default" in item
277
+ }
278
+ values.update(arguments)
279
+ if tool_name == "springboot_diagnose_health":
280
+ return HealthQuery(
281
+ target_id=values["targetId"],
282
+ max_results=values["maxResults"],
283
+ cursor=values.get("cursor"),
284
+ component_path=tuple(values.get("componentPath") or ()),
285
+ group=values["group"],
286
+ include_details=values["includeDetails"],
287
+ include_healthy=values["includeHealthy"],
288
+ max_depth=values["maxDepth"],
289
+ )
290
+ if tool_name == "springboot_verify_mappings":
291
+ return MappingQuery(
292
+ target_id=values["targetId"],
293
+ max_results=values["maxResults"],
294
+ cursor=values.get("cursor"),
295
+ path_pattern=values.get("pathPattern"),
296
+ http_method=values.get("httpMethod"),
297
+ controller_class_pattern=values.get("controllerClassPattern"),
298
+ context_id=values.get("contextId"),
299
+ )
300
+ if tool_name == "springboot_check_migrations":
301
+ expected = tuple(
302
+ ExpectedMigration(
303
+ engine=item["engine"],
304
+ context_id=item["contextId"],
305
+ migration_bean=item["migrationBean"],
306
+ identity=item["identity"],
307
+ version=item.get("version"),
308
+ checksum=item.get("checksum"),
309
+ )
310
+ for item in values.get("expectedMigrations") or ()
311
+ )
312
+ return MigrationQuery(
313
+ target_id=values["targetId"],
314
+ max_results=values["maxResults"],
315
+ cursor=values.get("cursor"),
316
+ engine=values["engine"],
317
+ context_id=values.get("contextId"),
318
+ migration_bean=values.get("migrationBean"),
319
+ states=tuple(values.get("state") or ()),
320
+ version=values.get("version"),
321
+ expected=expected,
322
+ expected_set_complete=values["expectedSetComplete"],
323
+ )
324
+ if tool_name == "springboot_analyze_startup":
325
+ return StartupQuery(
326
+ target_id=values["targetId"],
327
+ max_results=values["maxResults"],
328
+ cursor=values.get("cursor"),
329
+ step_name_pattern=values.get("stepNamePattern"),
330
+ bean_name_pattern=values.get("beanNamePattern"),
331
+ min_duration_ms=values["minDurationMs"],
332
+ max_depth=values["maxDepth"],
333
+ )
334
+ if tool_name == "springboot_inspect_config":
335
+ return ConfigQuery(
336
+ target_id=values["targetId"],
337
+ max_results=values["maxResults"],
338
+ cursor=values.get("cursor"),
339
+ prefix=values.get("prefix"),
340
+ property_key_pattern=values.get("propertyKeyPattern"),
341
+ context_id=values.get("contextId"),
342
+ include_values=values["includeValues"],
343
+ )
344
+ if tool_name == "springboot_detect_drift":
345
+ bean_count = (
346
+ len(arguments["expectedBeans"]) if "expectedBeans" in arguments else 0
347
+ )
348
+ route_count = (
349
+ len(arguments["expectedRoutes"]) if "expectedRoutes" in arguments else 0
350
+ )
351
+ migration_count = 1 if "expectedMigrationVersion" in arguments else 0
352
+ if bean_count + route_count + migration_count > 100:
353
+ raise NormalizationError("INVALID_ARGUMENT")
354
+ migration = arguments.get("expectedMigrationVersion")
355
+ return DriftQuery(
356
+ target_id=values["targetId"],
357
+ max_results=values["maxResults"],
358
+ cursor=values.get("cursor"),
359
+ expected_beans=_expected_beans(arguments["expectedBeans"])
360
+ if "expectedBeans" in arguments
361
+ else None,
362
+ expected_routes=_expected_routes(arguments["expectedRoutes"])
363
+ if "expectedRoutes" in arguments
364
+ else None,
365
+ expected_migration=ExpectedMigrationVersion(
366
+ engine=migration["engine"],
367
+ version=migration["version"],
368
+ context_id=migration.get("contextId"),
369
+ migration_bean=migration.get("migrationBean"),
370
+ )
371
+ if isinstance(migration, dict)
372
+ else None,
373
+ expected_profiles=tuple(arguments["expectedProfiles"])
374
+ if "expectedProfiles" in arguments
375
+ else None,
376
+ expected_scope_complete=values["expectedScopeComplete"],
377
+ source_revision=values.get("sourceRevision"),
378
+ source_modified_at=values.get("sourceModifiedAt"),
379
+ max_depth=values["maxDepth"],
380
+ )
381
+ if tool_name == "springboot_search_openapi":
382
+ return OpenApiQuery(
383
+ target_id=values["targetId"],
384
+ max_results=values["maxResults"],
385
+ cursor=values.get("cursor"),
386
+ path_pattern=values.get("pathPattern"),
387
+ http_method=values.get("httpMethod"),
388
+ operation_id=values.get("operationId"),
389
+ schema_name=values.get("schemaName"),
390
+ max_ref_depth=values["maxRefDepth"],
391
+ )
392
+ if tool_name != TOOL:
393
+ return values
394
+ return InspectBeansQuery(
395
+ target_id=values["targetId"],
396
+ max_results=values["maxResults"],
397
+ cursor=values.get("cursor"),
398
+ bean_name_pattern=values.get("beanNamePattern"),
399
+ type_pattern=values.get("typePattern"),
400
+ package_prefix=values.get("packagePrefix"),
401
+ context_id=values.get("contextId"),
402
+ direction=cast(
403
+ Literal["dependencies", "dependents", "both"], values["direction"]
404
+ ),
405
+ max_depth=values["maxDepth"],
406
+ condition_depth=values["conditionDepth"],
407
+ )
408
+
409
+
410
+ def glob_matches(pattern: str, value: str) -> bool:
411
+ """Linear-space glob DP; user input is never evaluated as a regex."""
412
+ if len(pattern) > 128 or len(value) > 4096:
413
+ return False
414
+ previous = [True] + [False] * len(value)
415
+ for char in pattern:
416
+ current = [False] * (len(value) + 1)
417
+ current[0] = char == "*" and previous[0]
418
+ for index, actual in enumerate(value, 1):
419
+ current[index] = (
420
+ previous[index] or current[index - 1]
421
+ if char == "*"
422
+ else previous[index - 1] and (char == "?" or char == actual)
423
+ )
424
+ previous = current
425
+ return previous[-1]
@@ -0,0 +1,254 @@
1
+ """Shared safe diagnostic envelopes and observation checks, independent of tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+ from .models.common import NormalizationError, ParsedObservation
10
+ from .policy.output_guard import (
11
+ DIAGNOSTIC_MESSAGE,
12
+ REVIEW_AUTHORIZATION,
13
+ REVIEW_STARTUP,
14
+ REVIEW_TARGET,
15
+ )
16
+ from .transport.bounded_json import validate_tree
17
+ from .transport.response_gate import HEALTH_ENDPOINTS
18
+
19
+ SAFE_CODES = frozenset(
20
+ [
21
+ "INVALID_ARGUMENT",
22
+ "TARGET_NOT_REGISTERED",
23
+ "OBSERVATION_IDENTITY_MISMATCH",
24
+ "ENDPOINT_UNAUTHORIZED",
25
+ "ENDPOINT_UNAVAILABLE",
26
+ "INVALID_OBSERVATION_TIME",
27
+ "CONTEXT_HIERARCHY_CONFLICT",
28
+ "CAPABILITY_DISABLED",
29
+ "CURSOR_EXPIRED",
30
+ "CURSOR_QUERY_MISMATCH",
31
+ "SNAPSHOT_TOO_LARGE",
32
+ "INVALID_RESPONSE",
33
+ "RESPONSE_TOO_LARGE",
34
+ "RESPONSE_HEADERS_TOO_LARGE",
35
+ "TARGET_TIMEOUT",
36
+ "GRAPH_LIMIT_REACHED",
37
+ "NORMALIZATION_LIMIT",
38
+ "NON_TREE_JSON",
39
+ "INVALID_JSON",
40
+ "INVALID_COMPRESSED_DATA",
41
+ "INCOMPLETE_RESPONSE",
42
+ "INVALID_JSON_NUMBER",
43
+ "UNSUPPORTED_SHAPE",
44
+ "INVALID_BEANS_STRUCTURE",
45
+ "INVALID_CONDITIONS_SHAPE",
46
+ "SANITIZATION_FAILED",
47
+ "MALFORMED_EVIDENCE",
48
+ "TARGET_CONNECTION_REFUSED",
49
+ "TARGET_CONNECTION_FAILED",
50
+ "REDIRECT_BLOCKED",
51
+ "ENDPOINT_NOT_EXPOSED",
52
+ "STARTUP_DATA_UNAVAILABLE",
53
+ "UNEXPECTED_CONTENT_TYPE",
54
+ "UNSUPPORTED_CONTENT_ENCODING",
55
+ "DUPLICATE_JSON_KEY",
56
+ "UNSAFE_OUTPUT",
57
+ "UNTRUSTED_OUTPUT",
58
+ "INTERNAL_ERROR",
59
+ "INVALID_HTTP_RESPONSE",
60
+ "FIXTURE_PATH_DENIED",
61
+ "REQUEST_CANCELLED",
62
+ ]
63
+ )
64
+ _DIAGNOSTIC_CODE = re.compile(r"[A-Z][A-Z0-9_]{0,63}\Z")
65
+
66
+
67
+ def safe_code(code: str) -> str:
68
+ if isinstance(code, str) and (
69
+ code in SAFE_CODES or _DIAGNOSTIC_CODE.fullmatch(code)
70
+ ):
71
+ return code
72
+ return "MALFORMED_EVIDENCE"
73
+
74
+
75
+ def error(
76
+ code: str, endpoint: str | None = None, status: int | None = None
77
+ ) -> dict[str, Any]:
78
+ code = safe_code(code)
79
+ endpoint = (
80
+ endpoint
81
+ if isinstance(endpoint, str)
82
+ and endpoint
83
+ in {
84
+ "beans",
85
+ "conditions",
86
+ "health",
87
+ "mappings",
88
+ "flyway",
89
+ "liquibase",
90
+ "startup",
91
+ "openapi",
92
+ "env",
93
+ "configprops",
94
+ "env-property",
95
+ "health-liveness",
96
+ "health-readiness",
97
+ "info",
98
+ }
99
+ else None
100
+ )
101
+ status = status if type(status) is int and 100 <= status <= 599 else None
102
+ action = (
103
+ "reviewAuthorization"
104
+ if code in ("ENDPOINT_UNAUTHORIZED", "REDIRECT_BLOCKED")
105
+ else "reviewExposure"
106
+ if code in ("ENDPOINT_UNAVAILABLE", "ENDPOINT_NOT_EXPOSED")
107
+ else "configureStartup"
108
+ if code == "STARTUP_DATA_UNAVAILABLE"
109
+ else "configureCapability"
110
+ if code == "CAPABILITY_DISABLED"
111
+ else "reviewTarget"
112
+ )
113
+ return {
114
+ "code": code,
115
+ "endpointId": endpoint,
116
+ "httpStatus": status,
117
+ "retryable": False,
118
+ "message": DIAGNOSTIC_MESSAGE,
119
+ "remediation": {
120
+ "recipeId": code,
121
+ "action": action,
122
+ "configKeys": ["management.endpoints.web.exposure.include"]
123
+ if action == "reviewExposure"
124
+ else [],
125
+ "steps": [REVIEW_STARTUP]
126
+ if code == "STARTUP_DATA_UNAVAILABLE"
127
+ else [REVIEW_AUTHORIZATION]
128
+ if action == "reviewAuthorization"
129
+ else [REVIEW_TARGET],
130
+ "requiresApproval": True,
131
+ "retryAfterMs": None,
132
+ },
133
+ }
134
+
135
+
136
+ def envelope(target: str = "local") -> dict[str, Any]:
137
+ return {
138
+ "schemaVersion": "2",
139
+ "status": "ok",
140
+ "source": {
141
+ "targetId": target,
142
+ "snapshotId": None,
143
+ "observedAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
144
+ "endpointIds": [],
145
+ "bootVersion": None,
146
+ "adapterVersion": "beans-1",
147
+ "policyVersion": "beans-1",
148
+ },
149
+ "data": {
150
+ "items": [],
151
+ "missingBeanAnalysis": {
152
+ "state": "notNeeded",
153
+ "conditions": [],
154
+ "blockingPaths": [],
155
+ },
156
+ },
157
+ "totalMatched": 0,
158
+ "truncated": False,
159
+ "truncationReasons": [],
160
+ "coverage": {
161
+ "state": "complete",
162
+ "scope": "registered target Bean contexts",
163
+ "gaps": [],
164
+ },
165
+ "warnings": [],
166
+ "redactions": [],
167
+ "nextCursor": None,
168
+ "nextAction": {
169
+ "kind": "done",
170
+ "reasonCode": "COMPLETE",
171
+ "filterSuggestions": [],
172
+ },
173
+ "errors": [],
174
+ }
175
+
176
+
177
+ def partial(result: dict[str, Any], gaps: list[str]) -> None:
178
+ if gaps:
179
+ result["status"] = "partial"
180
+ result["coverage"]["state"] = "partial"
181
+ result["coverage"]["gaps"] = sorted(set(result["coverage"]["gaps"] + gaps))[:10]
182
+ result["nextAction"] = {
183
+ "kind": "refine",
184
+ "reasonCode": "EVIDENCE_INCOMPLETE",
185
+ "filterSuggestions": [],
186
+ }
187
+
188
+
189
+ def failed(
190
+ code: str,
191
+ target: str = "local",
192
+ *,
193
+ endpoint: str | None = None,
194
+ http_status: int | None = None,
195
+ ) -> dict[str, Any]:
196
+ code = safe_code(code)
197
+ target = (
198
+ target
199
+ if isinstance(target, str) and re.fullmatch(r"[A-Za-z0-9_-]{1,64}", target)
200
+ else "local"
201
+ )
202
+ result = envelope(target)
203
+ result.update(
204
+ status="invalid"
205
+ if code == "INVALID_ARGUMENT"
206
+ else "denied"
207
+ if code in ("ENDPOINT_UNAUTHORIZED", "TARGET_NOT_REGISTERED")
208
+ else "unavailable",
209
+ data=None,
210
+ totalMatched=None,
211
+ errors=[error(code, endpoint, http_status)],
212
+ )
213
+ result["coverage"]["state"] = "unavailable"
214
+ result["nextAction"] = {
215
+ "kind": "remediate",
216
+ "reasonCode": code,
217
+ "filterSuggestions": [],
218
+ }
219
+ return result
220
+
221
+
222
+ def _check(
223
+ observation: ParsedObservation, target: str, snapshot: str, endpoint: str
224
+ ) -> None:
225
+ if (observation.target_id, observation.snapshot_id, observation.endpoint_id) != (
226
+ target,
227
+ snapshot,
228
+ endpoint,
229
+ ):
230
+ raise NormalizationError("OBSERVATION_IDENTITY_MISMATCH")
231
+ if observation.error_code is not None:
232
+ raise NormalizationError("ENDPOINT_UNAVAILABLE")
233
+ if observation.body is None or not (
234
+ observation.http_status == 200
235
+ or (observation.http_status == 503 and endpoint in HEALTH_ENDPOINTS)
236
+ ):
237
+ raise NormalizationError(
238
+ "ENDPOINT_UNAUTHORIZED"
239
+ if observation.http_status in (401, 403)
240
+ else "ENDPOINT_UNAVAILABLE"
241
+ )
242
+ if (
243
+ not isinstance(observation.observation_id, str)
244
+ or not observation.observation_id
245
+ or len(observation.observation_id) > 4096
246
+ ):
247
+ raise NormalizationError("OBSERVATION_IDENTITY_MISMATCH")
248
+ try:
249
+ date = datetime.fromisoformat(observation.observed_at.replace("Z", "+00:00"))
250
+ if date.tzinfo is None:
251
+ raise ValueError("timezone required")
252
+ except (ValueError, TypeError, AttributeError):
253
+ raise NormalizationError("INVALID_OBSERVATION_TIME") from None
254
+ validate_tree(observation.body)
@@ -0,0 +1 @@
1
+ """Internal models; public contracts live in spring_runtime_mcp/schemas."""