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,359 @@
|
|
|
1
|
+
"""Evidence-only source/type correlation and multihop condition attribution."""
|
|
2
|
+
|
|
3
|
+
from typing import Literal
|
|
4
|
+
|
|
5
|
+
from ..contracts.definitions import glob_matches
|
|
6
|
+
from ..models.bean_graph import BeanGraph, ContextKey
|
|
7
|
+
from ..models.common import AnalysisBudget, AnalysisLimit, Fact
|
|
8
|
+
from ..models.conditions import (
|
|
9
|
+
BlockingEdge,
|
|
10
|
+
Condition,
|
|
11
|
+
ConditionReport,
|
|
12
|
+
FailureAnalysis,
|
|
13
|
+
)
|
|
14
|
+
from ..models.queries import InspectBeansQuery
|
|
15
|
+
from ..models.source_catalog import SourceCatalog, SourceProducer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def is_assignable(
|
|
19
|
+
actual_type: str,
|
|
20
|
+
expected_type: str,
|
|
21
|
+
catalog: SourceCatalog,
|
|
22
|
+
*,
|
|
23
|
+
budget: AnalysisBudget,
|
|
24
|
+
) -> Fact[bool]:
|
|
25
|
+
if actual_type == expected_type:
|
|
26
|
+
return Fact("known", True, None)
|
|
27
|
+
pending: list[tuple[str, frozenset[str]]] = [(actual_type, frozenset())]
|
|
28
|
+
seen: set[str] = set()
|
|
29
|
+
incomplete = False
|
|
30
|
+
while pending:
|
|
31
|
+
current, ancestors = pending.pop()
|
|
32
|
+
budget.consume()
|
|
33
|
+
if current == expected_type:
|
|
34
|
+
return Fact("known", True, None)
|
|
35
|
+
if current in ancestors:
|
|
36
|
+
incomplete = True
|
|
37
|
+
continue
|
|
38
|
+
if current in seen:
|
|
39
|
+
continue
|
|
40
|
+
seen.add(current)
|
|
41
|
+
fact = catalog.types.get(current)
|
|
42
|
+
if fact is None:
|
|
43
|
+
incomplete = True
|
|
44
|
+
continue
|
|
45
|
+
pending.extend(
|
|
46
|
+
(parent, ancestors | {current}) for parent in fact.direct_supertypes
|
|
47
|
+
)
|
|
48
|
+
if incomplete or expected_type not in catalog.types:
|
|
49
|
+
return Fact("unknown", None, "SOURCE_TYPE_UNOBSERVED")
|
|
50
|
+
return Fact("known", False, None)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def producer_matches(
|
|
54
|
+
producer: SourceProducer,
|
|
55
|
+
query: InspectBeansQuery,
|
|
56
|
+
catalog: SourceCatalog,
|
|
57
|
+
*,
|
|
58
|
+
budget: AnalysisBudget,
|
|
59
|
+
) -> bool:
|
|
60
|
+
budget.consume()
|
|
61
|
+
if query.context_id is not None and producer.context_id != query.context_id:
|
|
62
|
+
return False
|
|
63
|
+
if query.bean_name_pattern is not None and not any(
|
|
64
|
+
glob_matches(query.bean_name_pattern, name)
|
|
65
|
+
for name in (producer.bean_name, *producer.aliases)
|
|
66
|
+
):
|
|
67
|
+
return False
|
|
68
|
+
if query.package_prefix and not any(
|
|
69
|
+
t.startswith(query.package_prefix.rstrip(".") + ".")
|
|
70
|
+
for t in (
|
|
71
|
+
producer.declared_type,
|
|
72
|
+
producer.implementation_type or producer.declared_type,
|
|
73
|
+
)
|
|
74
|
+
):
|
|
75
|
+
return False
|
|
76
|
+
if query.type_pattern:
|
|
77
|
+
types = set(catalog.types) | {
|
|
78
|
+
producer.declared_type,
|
|
79
|
+
producer.implementation_type or producer.declared_type,
|
|
80
|
+
}
|
|
81
|
+
expected = sorted(t for t in types if glob_matches(query.type_pattern, t))
|
|
82
|
+
return any(
|
|
83
|
+
is_assignable(actual, target, catalog, budget=budget).value is True
|
|
84
|
+
for actual in (
|
|
85
|
+
producer.declared_type,
|
|
86
|
+
producer.implementation_type or producer.declared_type,
|
|
87
|
+
)
|
|
88
|
+
for target in expected
|
|
89
|
+
)
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def analyze_missing_beans(
|
|
94
|
+
query: InspectBeansQuery,
|
|
95
|
+
graph: BeanGraph,
|
|
96
|
+
report: ConditionReport,
|
|
97
|
+
catalog: SourceCatalog,
|
|
98
|
+
*,
|
|
99
|
+
budget: AnalysisBudget,
|
|
100
|
+
) -> FailureAnalysis:
|
|
101
|
+
if catalog.target_id != query.target_id:
|
|
102
|
+
return FailureAnalysis(
|
|
103
|
+
"unknown", (), (), (), False, ("SOURCE_TARGET_MISMATCH",)
|
|
104
|
+
)
|
|
105
|
+
selected: dict[str, Condition] = {}
|
|
106
|
+
edges: list[BlockingEdge] = []
|
|
107
|
+
paths: list[tuple[str, ...]] = []
|
|
108
|
+
gaps = set(report.gaps) | set(catalog.gaps)
|
|
109
|
+
if report.coverage != "complete":
|
|
110
|
+
gaps.add("CONDITION_REPORT_INCOMPLETE")
|
|
111
|
+
truncated = False
|
|
112
|
+
contexts = {
|
|
113
|
+
key.context_id: key
|
|
114
|
+
for key in graph.contexts
|
|
115
|
+
if key.target_id == query.target_id
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
def visible(context: ContextKey, search: str) -> tuple[ContextKey, ...] | None:
|
|
119
|
+
if search == "unknown":
|
|
120
|
+
return None
|
|
121
|
+
result: list[ContextKey] = []
|
|
122
|
+
seen: set[ContextKey] = set()
|
|
123
|
+
current: ContextKey | None = context
|
|
124
|
+
while current is not None:
|
|
125
|
+
budget.consume()
|
|
126
|
+
if current in seen:
|
|
127
|
+
return None
|
|
128
|
+
seen.add(current)
|
|
129
|
+
item = graph.contexts.get(current)
|
|
130
|
+
if item is None or not item.complete:
|
|
131
|
+
return None
|
|
132
|
+
if search != "PARENTS" or current != context:
|
|
133
|
+
result.append(current)
|
|
134
|
+
if search == "CURRENT":
|
|
135
|
+
break
|
|
136
|
+
if item.parent.state != "known":
|
|
137
|
+
return None
|
|
138
|
+
current = item.parent.value
|
|
139
|
+
return tuple(result)
|
|
140
|
+
|
|
141
|
+
def producer_roots(producer: SourceProducer) -> tuple[str, ...]:
|
|
142
|
+
context = contexts.get(producer.context_id)
|
|
143
|
+
if context is None:
|
|
144
|
+
gaps.add("SOURCE_CONTEXT_UNOBSERVED")
|
|
145
|
+
return ()
|
|
146
|
+
result: list[str] = []
|
|
147
|
+
for source in producer.condition_sources:
|
|
148
|
+
result.extend(report.roots_by_source.get((context, source), ()))
|
|
149
|
+
return tuple(result)
|
|
150
|
+
|
|
151
|
+
def add_edge(parent: str, child: str, evidence: tuple[str, ...]) -> None:
|
|
152
|
+
edge = BlockingEdge(parent, child, "CONDITION_BLOCKS_PRODUCER", evidence)
|
|
153
|
+
if edge not in edges:
|
|
154
|
+
edges.append(edge)
|
|
155
|
+
|
|
156
|
+
def visit(
|
|
157
|
+
node_id: str,
|
|
158
|
+
path: tuple[str, ...],
|
|
159
|
+
depth: int,
|
|
160
|
+
active_producers: frozenset[str],
|
|
161
|
+
) -> bool:
|
|
162
|
+
nonlocal truncated
|
|
163
|
+
budget.consume()
|
|
164
|
+
if node_id in path:
|
|
165
|
+
gaps.add("CONDITION_CYCLE")
|
|
166
|
+
return False
|
|
167
|
+
if depth > min(query.condition_depth, budget.max_depth):
|
|
168
|
+
truncated = True
|
|
169
|
+
gaps.add("GRAPH_LIMIT_REACHED")
|
|
170
|
+
return False
|
|
171
|
+
node = report.nodes.get(node_id)
|
|
172
|
+
if node is None:
|
|
173
|
+
gaps.add("CONDITION_REFERENCE_UNOBSERVED")
|
|
174
|
+
return False
|
|
175
|
+
selected[node_id] = node
|
|
176
|
+
chain = (*path, node_id)
|
|
177
|
+
if not node.children_complete or node.reason_code == "CONDITION_PARSE_CONFLICT":
|
|
178
|
+
gaps.add(node.reason_code)
|
|
179
|
+
return False
|
|
180
|
+
if node.reported_outcome != "notMatched":
|
|
181
|
+
return False
|
|
182
|
+
if node.operator in ("any", "all", "none"):
|
|
183
|
+
children = [
|
|
184
|
+
child
|
|
185
|
+
for child in node.child_ids
|
|
186
|
+
if report.nodes[child].reported_outcome == "notMatched"
|
|
187
|
+
]
|
|
188
|
+
if node.operator == "none" or not children:
|
|
189
|
+
gaps.add("CONDITION_NOT_A_MISSING_BEAN_CHAIN")
|
|
190
|
+
return False
|
|
191
|
+
results = [
|
|
192
|
+
visit(child, chain, depth + 1, active_producers) for child in children
|
|
193
|
+
]
|
|
194
|
+
return all(results) if node.operator == "any" else any(results)
|
|
195
|
+
if node.reason_code in ("PROPERTY_NOT_FOUND", "PROPERTY_VALUE_MISMATCH"):
|
|
196
|
+
paths.append(chain)
|
|
197
|
+
return True
|
|
198
|
+
if node.reason_code != "REQUIRED_BEAN_MISSING":
|
|
199
|
+
gaps.add("UNRESOLVED_CONDITION_CAUSE")
|
|
200
|
+
return False
|
|
201
|
+
# A combined requirement does not prove which individual type/name
|
|
202
|
+
# failed. Never invent one causal edge from an undifferentiated list.
|
|
203
|
+
if len(node.required_names) + len(node.required_types) != 1:
|
|
204
|
+
gaps.add("MULTI_BEAN_REQUIREMENTS_UNSUPPORTED")
|
|
205
|
+
return False
|
|
206
|
+
scope = visible(node.context, node.search)
|
|
207
|
+
if scope is None:
|
|
208
|
+
gaps.add("PARENT_CONTEXT_UNOBSERVED")
|
|
209
|
+
return False
|
|
210
|
+
# Reported missing state must not conflict with visible runtime facts.
|
|
211
|
+
runtime_exists = False
|
|
212
|
+
for bean in graph.beans.values():
|
|
213
|
+
if bean.key.context not in scope:
|
|
214
|
+
continue
|
|
215
|
+
budget.consume()
|
|
216
|
+
if any(
|
|
217
|
+
name in (bean.key.canonical_name, *bean.aliases)
|
|
218
|
+
for name in node.required_names
|
|
219
|
+
):
|
|
220
|
+
runtime_exists = True
|
|
221
|
+
if (
|
|
222
|
+
bean.reported_type.state == "known"
|
|
223
|
+
and bean.reported_type.value is not None
|
|
224
|
+
):
|
|
225
|
+
for required in node.required_types:
|
|
226
|
+
fact = is_assignable(
|
|
227
|
+
bean.reported_type.value, required, catalog, budget=budget
|
|
228
|
+
)
|
|
229
|
+
runtime_exists = runtime_exists or fact.value is True
|
|
230
|
+
if runtime_exists:
|
|
231
|
+
gaps.add("CONDITION_RUNTIME_CONFLICT")
|
|
232
|
+
return False
|
|
233
|
+
# The official missing report is evidence even when unrelated runtime
|
|
234
|
+
# types lack source inheritance facts; it is not inferred from enumeration.
|
|
235
|
+
candidates: list[SourceProducer] = []
|
|
236
|
+
for producer in catalog.producers:
|
|
237
|
+
budget.consume()
|
|
238
|
+
if producer.context_id not in {ctx.context_id for ctx in scope}:
|
|
239
|
+
continue
|
|
240
|
+
by_name = any(
|
|
241
|
+
name in (producer.bean_name, *producer.aliases)
|
|
242
|
+
for name in node.required_names
|
|
243
|
+
)
|
|
244
|
+
by_type = any(
|
|
245
|
+
is_assignable(
|
|
246
|
+
producer.implementation_type or producer.declared_type,
|
|
247
|
+
required,
|
|
248
|
+
catalog,
|
|
249
|
+
budget=budget,
|
|
250
|
+
).value
|
|
251
|
+
is True
|
|
252
|
+
for required in node.required_types
|
|
253
|
+
)
|
|
254
|
+
if by_name or by_type:
|
|
255
|
+
candidates.append(producer)
|
|
256
|
+
if (
|
|
257
|
+
len(candidates) != 1
|
|
258
|
+
or len(node.required_names) + len(node.required_types) != 1
|
|
259
|
+
):
|
|
260
|
+
gaps.add(
|
|
261
|
+
"SOURCE_PRODUCER_AMBIGUOUS"
|
|
262
|
+
if candidates
|
|
263
|
+
else "SOURCE_FACTS_UNAVAILABLE"
|
|
264
|
+
)
|
|
265
|
+
return False
|
|
266
|
+
producer = candidates[0]
|
|
267
|
+
if producer.id in active_producers:
|
|
268
|
+
gaps.add("CONDITION_CYCLE")
|
|
269
|
+
return False
|
|
270
|
+
roots = [
|
|
271
|
+
rid
|
|
272
|
+
for rid in producer_roots(producer)
|
|
273
|
+
if report.nodes[rid].reported_outcome == "notMatched"
|
|
274
|
+
]
|
|
275
|
+
if not roots:
|
|
276
|
+
gaps.add("SOURCE_CONDITIONS_UNOBSERVED")
|
|
277
|
+
return False
|
|
278
|
+
parent_id = node_id
|
|
279
|
+
next_path = chain
|
|
280
|
+
next_depth = depth + 1
|
|
281
|
+
if producer.factory_method:
|
|
282
|
+
factory_id = f"factory_{producer.id}"
|
|
283
|
+
factory = Condition(
|
|
284
|
+
factory_id,
|
|
285
|
+
contexts[producer.context_id],
|
|
286
|
+
producer.configuration_class + "#" + producer.factory_method,
|
|
287
|
+
None,
|
|
288
|
+
"leaf",
|
|
289
|
+
"notMatched",
|
|
290
|
+
"notMatched",
|
|
291
|
+
"unknown",
|
|
292
|
+
(),
|
|
293
|
+
(),
|
|
294
|
+
Fact("notReported", None, "NOT_REPORTED"),
|
|
295
|
+
(),
|
|
296
|
+
True,
|
|
297
|
+
"SOURCE_FACTORY_NOT_CREATED",
|
|
298
|
+
producer.evidence_ids,
|
|
299
|
+
)
|
|
300
|
+
if next_depth > query.condition_depth:
|
|
301
|
+
truncated = True
|
|
302
|
+
gaps.add("GRAPH_LIMIT_REACHED")
|
|
303
|
+
return False
|
|
304
|
+
selected[factory_id] = factory
|
|
305
|
+
add_edge(node_id, factory_id, (*node.evidence_ids, *producer.evidence_ids))
|
|
306
|
+
parent_id = factory_id
|
|
307
|
+
next_path = (*chain, factory_id)
|
|
308
|
+
next_depth += 1
|
|
309
|
+
result = False
|
|
310
|
+
for root in roots:
|
|
311
|
+
if next_depth <= query.condition_depth:
|
|
312
|
+
add_edge(parent_id, root, (*node.evidence_ids, *producer.evidence_ids))
|
|
313
|
+
result = (
|
|
314
|
+
visit(root, next_path, next_depth, active_producers | {producer.id})
|
|
315
|
+
or result
|
|
316
|
+
)
|
|
317
|
+
return result
|
|
318
|
+
|
|
319
|
+
explained = False
|
|
320
|
+
try:
|
|
321
|
+
candidates = [
|
|
322
|
+
p
|
|
323
|
+
for p in catalog.producers
|
|
324
|
+
if producer_matches(p, query, catalog, budget=budget)
|
|
325
|
+
]
|
|
326
|
+
if not candidates:
|
|
327
|
+
gaps.add("SOURCE_FACTS_UNAVAILABLE")
|
|
328
|
+
if len(candidates) > 1:
|
|
329
|
+
gaps.add("SOURCE_PRODUCER_AMBIGUOUS")
|
|
330
|
+
for producer in candidates:
|
|
331
|
+
roots = [
|
|
332
|
+
rid
|
|
333
|
+
for rid in producer_roots(producer)
|
|
334
|
+
if report.nodes[rid].reported_outcome == "notMatched"
|
|
335
|
+
]
|
|
336
|
+
if not roots:
|
|
337
|
+
gaps.add("SOURCE_CONDITIONS_UNOBSERVED")
|
|
338
|
+
for root in roots:
|
|
339
|
+
explained = visit(root, (), 0, frozenset({producer.id})) or explained
|
|
340
|
+
except AnalysisLimit:
|
|
341
|
+
truncated = True
|
|
342
|
+
gaps.add("GRAPH_LIMIT_REACHED")
|
|
343
|
+
if graph.coverage != "complete":
|
|
344
|
+
gaps.add("BEAN_INDEX_INCOMPLETE")
|
|
345
|
+
state: Literal["explained", "partial", "unknown"] = (
|
|
346
|
+
"explained"
|
|
347
|
+
if explained and not gaps and not truncated
|
|
348
|
+
else "partial"
|
|
349
|
+
if selected
|
|
350
|
+
else "unknown"
|
|
351
|
+
)
|
|
352
|
+
return FailureAnalysis(
|
|
353
|
+
state,
|
|
354
|
+
tuple(selected.values()),
|
|
355
|
+
tuple(edges),
|
|
356
|
+
tuple(paths),
|
|
357
|
+
truncated,
|
|
358
|
+
tuple(sorted(gaps)),
|
|
359
|
+
)
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Operator entrypoint. Tool arguments cannot select the actuator origin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
from . import __version__
|
|
13
|
+
from .adapters.source_catalog import load_source_catalog
|
|
14
|
+
from .models.common import AnalysisBudget, NormalizationError
|
|
15
|
+
from .models.source_catalog import SourceCatalog
|
|
16
|
+
from .policy.configuration import ConfigReadPolicy
|
|
17
|
+
from .server import build_services, run_stdio
|
|
18
|
+
from .services import Services
|
|
19
|
+
from .transport.bounded_json import loads_bounded
|
|
20
|
+
|
|
21
|
+
SOURCE_CATALOG_MAX_BYTES = 262144
|
|
22
|
+
|
|
23
|
+
_CONFIG_HINTS = {
|
|
24
|
+
"INVALID_BASE_URL": (
|
|
25
|
+
"--base-url must be a plain http(s) origin without a path, query, "
|
|
26
|
+
"credentials or fragment"
|
|
27
|
+
),
|
|
28
|
+
"INVALID_TIMEOUT": "--timeout must be greater than 0 and at most 60 seconds",
|
|
29
|
+
"INVALID_TARGET_REGISTRATION": "--target-id must match [A-Za-z0-9_-]{1,64}",
|
|
30
|
+
"INVALID_CREDENTIAL_REGISTRATION": (
|
|
31
|
+
"--token-env names an environment variable whose value is not a "
|
|
32
|
+
"supported Bearer token"
|
|
33
|
+
),
|
|
34
|
+
"ADDRESS_DENIED": (
|
|
35
|
+
"--base-url must resolve only to loopback addresses (127.0.0.0/8 or ::1)"
|
|
36
|
+
),
|
|
37
|
+
"SOURCE_TARGET_MISMATCH": "--source-catalog targetId must equal --target-id",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfigurationError(Exception):
|
|
42
|
+
"""Operator-facing startup failure; text never contains a value."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _timeout(value: str) -> float:
|
|
46
|
+
try:
|
|
47
|
+
timeout = float(value)
|
|
48
|
+
except (TypeError, ValueError):
|
|
49
|
+
raise ConfigurationError(_CONFIG_HINTS["INVALID_TIMEOUT"]) from None
|
|
50
|
+
if not 0 < timeout <= 60:
|
|
51
|
+
raise ConfigurationError(_CONFIG_HINTS["INVALID_TIMEOUT"])
|
|
52
|
+
return timeout
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _bearer(name: str | None) -> str | None:
|
|
56
|
+
if name is None:
|
|
57
|
+
return None
|
|
58
|
+
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]{0,127}", name) is None:
|
|
59
|
+
raise ConfigurationError(
|
|
60
|
+
"--token-env must be a valid environment variable name"
|
|
61
|
+
)
|
|
62
|
+
token = os.environ.get(name)
|
|
63
|
+
if not token:
|
|
64
|
+
raise ConfigurationError(
|
|
65
|
+
f"environment variable {name} named by --token-env is missing or empty"
|
|
66
|
+
)
|
|
67
|
+
return token
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_source_catalog(path: str) -> bytes:
|
|
71
|
+
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NONBLOCK", 0)
|
|
72
|
+
descriptor = os.open(path, flags)
|
|
73
|
+
try:
|
|
74
|
+
if not stat.S_ISREG(os.fstat(descriptor).st_mode):
|
|
75
|
+
raise ConfigurationError("--source-catalog must name a regular file")
|
|
76
|
+
chunks: list[bytes] = []
|
|
77
|
+
remaining = SOURCE_CATALOG_MAX_BYTES + 1
|
|
78
|
+
while remaining:
|
|
79
|
+
chunk = os.read(descriptor, min(65536, remaining))
|
|
80
|
+
if not chunk:
|
|
81
|
+
break
|
|
82
|
+
chunks.append(chunk)
|
|
83
|
+
remaining -= len(chunk)
|
|
84
|
+
finally:
|
|
85
|
+
os.close(descriptor)
|
|
86
|
+
if sum(map(len, chunks)) > SOURCE_CATALOG_MAX_BYTES:
|
|
87
|
+
raise ConfigurationError(
|
|
88
|
+
f"--source-catalog exceeds {SOURCE_CATALOG_MAX_BYTES} bytes"
|
|
89
|
+
)
|
|
90
|
+
return b"".join(chunks)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _catalog(path: str, target_id: str) -> SourceCatalog:
|
|
94
|
+
try:
|
|
95
|
+
payload = loads_bounded(
|
|
96
|
+
_read_source_catalog(path), max_bytes=SOURCE_CATALOG_MAX_BYTES
|
|
97
|
+
)
|
|
98
|
+
return load_source_catalog(
|
|
99
|
+
payload, target_id=target_id, budget=AnalysisBudget()
|
|
100
|
+
)
|
|
101
|
+
except ConfigurationError:
|
|
102
|
+
raise
|
|
103
|
+
except OSError:
|
|
104
|
+
raise ConfigurationError(
|
|
105
|
+
"--source-catalog could not be opened or read"
|
|
106
|
+
) from None
|
|
107
|
+
except (NormalizationError, UnicodeError) as exc:
|
|
108
|
+
code = (
|
|
109
|
+
exc.code
|
|
110
|
+
if isinstance(exc, NormalizationError)
|
|
111
|
+
else "INVALID_SOURCE_CATALOG"
|
|
112
|
+
)
|
|
113
|
+
raise ConfigurationError(f"--source-catalog is invalid ({code})") from None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _config_policy(args: argparse.Namespace) -> ConfigReadPolicy | None:
|
|
117
|
+
enabled = (
|
|
118
|
+
args.config_prefix
|
|
119
|
+
or args.config_property
|
|
120
|
+
or args.config_profiles
|
|
121
|
+
or args.config_allow_values
|
|
122
|
+
)
|
|
123
|
+
if not enabled:
|
|
124
|
+
return None
|
|
125
|
+
if not (args.config_prefix or args.config_property or args.config_profiles):
|
|
126
|
+
raise ConfigurationError(
|
|
127
|
+
"--config-allow-values requires --config-prefix, --config-property "
|
|
128
|
+
"or --config-profiles"
|
|
129
|
+
)
|
|
130
|
+
try:
|
|
131
|
+
return ConfigReadPolicy(
|
|
132
|
+
properties=frozenset(args.config_property),
|
|
133
|
+
prefixes=frozenset(args.config_prefix),
|
|
134
|
+
allow_values=args.config_allow_values,
|
|
135
|
+
profiles=args.config_profiles,
|
|
136
|
+
)
|
|
137
|
+
except ValueError:
|
|
138
|
+
raise ConfigurationError(
|
|
139
|
+
"--config-prefix, --config-property and --config-profiles must use "
|
|
140
|
+
"valid registration names"
|
|
141
|
+
) from None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _services(
|
|
145
|
+
args: argparse.Namespace,
|
|
146
|
+
*,
|
|
147
|
+
timeout: float,
|
|
148
|
+
catalog: SourceCatalog | None,
|
|
149
|
+
config_policy: ConfigReadPolicy | None,
|
|
150
|
+
bearer_token: str | None,
|
|
151
|
+
) -> Services:
|
|
152
|
+
try:
|
|
153
|
+
return build_services(
|
|
154
|
+
args.base_url,
|
|
155
|
+
args.target_id,
|
|
156
|
+
timeout,
|
|
157
|
+
source_catalog=catalog,
|
|
158
|
+
config_policy=config_policy,
|
|
159
|
+
bearer_token=bearer_token,
|
|
160
|
+
)
|
|
161
|
+
except NormalizationError as exc:
|
|
162
|
+
raise ConfigurationError(
|
|
163
|
+
_CONFIG_HINTS.get(exc.code, "target registration is invalid")
|
|
164
|
+
) from None
|
|
165
|
+
except ValueError as exc:
|
|
166
|
+
raise ConfigurationError(
|
|
167
|
+
_CONFIG_HINTS.get(str(exc), "server configuration is invalid")
|
|
168
|
+
) from None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def main(argv: list[str] | None = None) -> None:
|
|
172
|
+
parser = argparse.ArgumentParser(prog="spring-runtime-mcp")
|
|
173
|
+
parser.add_argument(
|
|
174
|
+
"--version", action="version", version=f"%(prog)s {__version__}"
|
|
175
|
+
)
|
|
176
|
+
parser.add_argument(
|
|
177
|
+
"--base-url",
|
|
178
|
+
default=os.environ.get("SPRING_RUNTIME_BASE_URL", "http://127.0.0.1:18081"),
|
|
179
|
+
)
|
|
180
|
+
parser.add_argument(
|
|
181
|
+
"--target-id",
|
|
182
|
+
default=os.environ.get("SPRING_RUNTIME_TARGET_ID", "local"),
|
|
183
|
+
)
|
|
184
|
+
parser.add_argument(
|
|
185
|
+
"--timeout",
|
|
186
|
+
default=os.environ.get("SPRING_RUNTIME_TIMEOUT", "5"),
|
|
187
|
+
)
|
|
188
|
+
parser.add_argument(
|
|
189
|
+
"--source-catalog",
|
|
190
|
+
default=os.environ.get("SPRING_RUNTIME_SOURCE_CATALOG"),
|
|
191
|
+
help="Operator-registered JSON workspace facts for this target.",
|
|
192
|
+
)
|
|
193
|
+
parser.add_argument(
|
|
194
|
+
"--token-env",
|
|
195
|
+
help="Name of the environment variable holding this target's Bearer token.",
|
|
196
|
+
)
|
|
197
|
+
parser.add_argument(
|
|
198
|
+
"--config-prefix",
|
|
199
|
+
action="append",
|
|
200
|
+
default=[],
|
|
201
|
+
metavar="PREFIX",
|
|
202
|
+
help="Register one property prefix for opt-in config diagnostics.",
|
|
203
|
+
)
|
|
204
|
+
parser.add_argument(
|
|
205
|
+
"--config-property",
|
|
206
|
+
action="append",
|
|
207
|
+
default=[],
|
|
208
|
+
metavar="KEY",
|
|
209
|
+
help="Register one property key for opt-in config diagnostics.",
|
|
210
|
+
)
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--config-profiles",
|
|
213
|
+
action="store_true",
|
|
214
|
+
help="Allow reading active profiles for drift comparison.",
|
|
215
|
+
)
|
|
216
|
+
parser.add_argument(
|
|
217
|
+
"--config-allow-values",
|
|
218
|
+
action="store_true",
|
|
219
|
+
help="Allow registered config values after sanitization (off by default).",
|
|
220
|
+
)
|
|
221
|
+
args = parser.parse_args(argv)
|
|
222
|
+
try:
|
|
223
|
+
timeout = _timeout(args.timeout)
|
|
224
|
+
bearer_token = _bearer(args.token_env)
|
|
225
|
+
catalog = (
|
|
226
|
+
_catalog(args.source_catalog, args.target_id)
|
|
227
|
+
if args.source_catalog
|
|
228
|
+
else None
|
|
229
|
+
)
|
|
230
|
+
config_policy = _config_policy(args)
|
|
231
|
+
services = _services(
|
|
232
|
+
args,
|
|
233
|
+
timeout=timeout,
|
|
234
|
+
catalog=catalog,
|
|
235
|
+
config_policy=config_policy,
|
|
236
|
+
bearer_token=bearer_token,
|
|
237
|
+
)
|
|
238
|
+
except ConfigurationError as exc:
|
|
239
|
+
sys.stderr.write(f"spring-runtime-mcp: {exc}\n")
|
|
240
|
+
raise SystemExit(2) from None
|
|
241
|
+
try:
|
|
242
|
+
asyncio.run(run_stdio(services))
|
|
243
|
+
except KeyboardInterrupt:
|
|
244
|
+
raise SystemExit(130) from None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tool input validation and bounded protocol serialization."""
|