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,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."""
|