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