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,79 @@
1
+ """Read-only local OpenAPI search. Remote $ref values are not resolved."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from ..adapters.openapi import normalize_openapi
10
+ from ..contracts.definitions import validate_arguments
11
+ from ..models.common import NormalizationError
12
+ from ..models.queries import OpenApiQuery
13
+ from ..transport.response_gate import EndpointReadError
14
+ from ..services import Services
15
+ from ..diagnostics import failed, partial
16
+ from .support import tool_handler, finalize, read_checked, resume, shell, stamp
17
+
18
+
19
+ @tool_handler
20
+ async def search_openapi(
21
+ arguments: Mapping[str, Any], services: Services
22
+ ) -> dict[str, Any]:
23
+ request_target = "local"
24
+ try:
25
+ query = validate_arguments(arguments, "springboot_search_openapi")
26
+ request_target = query.target_id
27
+ assert isinstance(query, OpenApiQuery)
28
+ if query.cursor:
29
+ return resume(services, query)
30
+ snapshot = uuid4().hex
31
+ observation = await read_checked(services, query.target_id, "openapi", snapshot)
32
+ version, candidate, items, gaps, blocked = normalize_openapi(
33
+ observation.body, query
34
+ )
35
+ result = shell(query.target_id, "registered target openapi", "openapi-1")
36
+ stamp(result, observation, "openapi", snapshot)
37
+ for item in items:
38
+ item["endpointId"] = "openapi"
39
+ item["observationId"] = observation.observation_id
40
+ result["data"] = {
41
+ "openapiVersion": version,
42
+ "candidateId": candidate,
43
+ "items": items,
44
+ }
45
+ enumeration_gaps = {
46
+ "OPENAPI_PATH_UNREADABLE",
47
+ "OPENAPI_PATH_FIELD_CONFLICT",
48
+ "OPENAPI_OPERATION_UNREADABLE",
49
+ }
50
+ result["totalMatched"] = (
51
+ None if enumeration_gaps.intersection(gaps) else len(items)
52
+ )
53
+ partial(result, gaps)
54
+ limit_codes = ("REF_DEPTH_LIMIT", "REF_CYCLE", "REF_EXPANSION_LIMIT")
55
+ if any(code in gaps for code in limit_codes):
56
+ reason = next(code for code in limit_codes if code in gaps)
57
+ result["truncated"] = True
58
+ result["truncationReasons"] = [code for code in limit_codes if code in gaps]
59
+ result["nextAction"] = {
60
+ "kind": "refine",
61
+ "reasonCode": reason,
62
+ "filterSuggestions": (
63
+ [{"parameter": "schemaName", "value": blocked}] if blocked else []
64
+ ),
65
+ }
66
+ return finalize(result, services, query)
67
+ except NormalizationError as exc:
68
+ return failed(
69
+ exc.code,
70
+ request_target,
71
+ endpoint=exc.endpoint_id if isinstance(exc, EndpointReadError) else None,
72
+ http_status=exc.http_status if isinstance(exc, EndpointReadError) else None,
73
+ )
74
+ except TimeoutError:
75
+ return failed("TARGET_TIMEOUT", request_target)
76
+ except (ValueError, TypeError, KeyError, RecursionError, UnicodeError):
77
+ return failed("MALFORMED_EVIDENCE", request_target)
78
+ except Exception:
79
+ return failed("INTERNAL_ERROR", request_target)
@@ -0,0 +1,166 @@
1
+ """Shared envelope, observation check, and byte-budget paging for diagnostic tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import time
7
+ from collections.abc import Awaitable, Callable, Mapping
8
+ from datetime import UTC, datetime
9
+ from functools import wraps
10
+ from typing import Any
11
+
12
+ from ..models.common import CALL_BUDGET, CallBudget, NormalizationError
13
+ from ..snapshots import SnapshotStore, page_envelope
14
+ from ..transport.responses import serialize_response
15
+ from ..services import Services
16
+ from ..diagnostics import _check, failed, partial
17
+
18
+ TOOL_TIMEOUT_SECONDS = 15.0
19
+ ToolHandler = Callable[[Mapping[str, Any], Services], Awaitable[dict[str, Any]]]
20
+
21
+
22
+ def tool_handler(handler: ToolHandler) -> ToolHandler:
23
+ """Bound reads, queued work and cooperative analysis under a single scope."""
24
+
25
+ @wraps(handler)
26
+ async def bounded(
27
+ arguments: Mapping[str, Any], services: Services
28
+ ) -> dict[str, Any]:
29
+ parent = CALL_BUDGET.get()
30
+ scope = parent or CallBudget(time.monotonic() + TOOL_TIMEOUT_SECONDS)
31
+ token = CALL_BUDGET.set(scope)
32
+ target = (
33
+ arguments.get("targetId", "local")
34
+ if isinstance(arguments, Mapping)
35
+ else "local"
36
+ )
37
+ try:
38
+ async with asyncio.timeout(scope.check()):
39
+ result = await handler(arguments, services)
40
+ scope.check()
41
+ return result
42
+ except TimeoutError:
43
+ return failed("TARGET_TIMEOUT", target)
44
+ except NormalizationError as exc:
45
+ return failed(exc.code, target)
46
+ finally:
47
+ CALL_BUDGET.reset(token)
48
+
49
+ return bounded
50
+
51
+
52
+ def shell(target: str, scope: str, adapter: str) -> dict[str, Any]:
53
+ return {
54
+ "schemaVersion": "2",
55
+ "status": "ok",
56
+ "source": {
57
+ "targetId": target,
58
+ "snapshotId": None,
59
+ "observedAt": datetime.now(UTC).isoformat().replace("+00:00", "Z"),
60
+ "endpointIds": [],
61
+ "bootVersion": None,
62
+ "adapterVersion": adapter,
63
+ "policyVersion": adapter,
64
+ },
65
+ "data": {"items": []},
66
+ "totalMatched": 0,
67
+ "truncated": False,
68
+ "truncationReasons": [],
69
+ "coverage": {"state": "complete", "scope": scope, "gaps": []},
70
+ "warnings": [],
71
+ "redactions": [],
72
+ "nextCursor": None,
73
+ "nextAction": {
74
+ "kind": "done",
75
+ "reasonCode": "COMPLETE",
76
+ "filterSuggestions": [],
77
+ },
78
+ "errors": [],
79
+ }
80
+
81
+
82
+ def stamp(
83
+ result: dict[str, Any], observation: Any, endpoint: str, snapshot: str
84
+ ) -> None:
85
+ result["source"].update(
86
+ snapshotId=snapshot,
87
+ endpointIds=sorted({*result["source"]["endpointIds"], endpoint}),
88
+ observedAt=datetime.fromisoformat(
89
+ observation.observed_at.replace("Z", "+00:00")
90
+ )
91
+ .astimezone(UTC)
92
+ .isoformat()
93
+ .replace("+00:00", "Z"),
94
+ )
95
+
96
+
97
+ async def read_checked(
98
+ services: Services, target: str, endpoint: str, snapshot: str
99
+ ) -> Any:
100
+ if target not in services.targets or services.catalog.target_id != target:
101
+ raise NormalizationError("TARGET_NOT_REGISTERED")
102
+ if endpoint in {"env", "configprops"} and services.config_policy is None:
103
+ from ..transport.response_gate import EndpointReadError
104
+
105
+ raise EndpointReadError("CAPABILITY_DISABLED", endpoint)
106
+ observation = await services.read(target, endpoint, snapshot_id=snapshot)
107
+ _check(observation, target, snapshot, endpoint)
108
+ return observation
109
+
110
+
111
+ def finalize(
112
+ result: dict[str, Any], services: Services, query: Any, *, more: bool = False
113
+ ) -> dict[str, Any]:
114
+ """Slice items to the query limit and the serialized response budget."""
115
+ result = services.sanitizer.sanitize(result)
116
+ if not isinstance(result.get("data"), dict):
117
+ return result
118
+ items = list(result["data"].get("items") or [])
119
+ count = min(len(items), query.max_results)
120
+ while True:
121
+ page = page_envelope(result, items[:count])
122
+ remaining = more or count < len(items)
123
+ if remaining:
124
+ page["truncated"] = True
125
+ page["truncationReasons"] = sorted(
126
+ set(page["truncationReasons"] + ["MAX_RESULTS"])
127
+ )[:8]
128
+ partial(page, ["MAX_RESULTS"])
129
+ page["nextAction"] = {
130
+ "kind": "page",
131
+ "reasonCode": "MORE_RESULTS",
132
+ "filterSuggestions": [],
133
+ }
134
+ page["nextCursor"] = "x" * 64
135
+ try:
136
+ serialize_response(page, "\0" * 128, sanitizer=services.sanitizer)
137
+ break
138
+ except NormalizationError as exc:
139
+ if exc.code != "RESPONSE_TOO_LARGE":
140
+ raise
141
+ if count <= 1:
142
+ return failed("RESPONSE_TOO_LARGE", query.target_id)
143
+ count //= 2
144
+ if remaining:
145
+ page["nextCursor"] = (
146
+ services.snapshots.advance(
147
+ query.cursor, query, count, sanitizer=services.sanitizer
148
+ )
149
+ if query.cursor
150
+ else services.snapshots.put_paged(
151
+ result, query, offset=count, sanitizer=services.sanitizer
152
+ )
153
+ )
154
+ serialize_response(page, "\0" * 128, sanitizer=services.sanitizer)
155
+ return page
156
+
157
+
158
+ def resume(services: Services, query: Any) -> dict[str, Any]:
159
+ stored, more = services.snapshots.get_page(
160
+ query.cursor, query, sanitizer=services.sanitizer
161
+ )
162
+ return finalize(stored, services, query, more=more)
163
+
164
+
165
+ # Re-export for handlers that only need the store type in annotations.
166
+ __all__ = ["SnapshotStore", "finalize", "read_checked", "resume", "shell", "stamp"]
@@ -0,0 +1,62 @@
1
+ """Read-only MVC and WebFlux mapping handler."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from typing import Any
7
+ from uuid import uuid4
8
+
9
+ from ..adapters.mappings import normalize_mappings
10
+ from ..contracts.definitions import validate_arguments
11
+ from ..models.common import NormalizationError
12
+ from ..models.queries import MappingQuery
13
+ from ..transport.response_gate import EndpointReadError
14
+ from ..services import Services
15
+ from ..diagnostics import failed, partial
16
+ from .support import tool_handler, finalize, read_checked, resume, shell, stamp
17
+
18
+
19
+ @tool_handler
20
+ async def verify_mappings(
21
+ arguments: Mapping[str, Any], services: Services
22
+ ) -> dict[str, Any]:
23
+ request_target = "local"
24
+ try:
25
+ query = validate_arguments(arguments, "springboot_verify_mappings")
26
+ request_target = query.target_id
27
+ assert isinstance(query, MappingQuery)
28
+ if query.cursor:
29
+ return resume(services, query)
30
+ snapshot = uuid4().hex
31
+ observation = await read_checked(
32
+ services, query.target_id, "mappings", snapshot
33
+ )
34
+ items, gaps = normalize_mappings(
35
+ observation.body,
36
+ path_pattern=query.path_pattern,
37
+ http_method=query.http_method,
38
+ controller_pattern=query.controller_class_pattern,
39
+ context_id=query.context_id,
40
+ )
41
+ result = shell(query.target_id, "registered target mappings", "mappings-1")
42
+ stamp(result, observation, "mappings", snapshot)
43
+ for item in items:
44
+ item["endpointId"] = "mappings"
45
+ item["observationId"] = observation.observation_id
46
+ result["data"] = {"items": items}
47
+ result["totalMatched"] = None if gaps else len(items)
48
+ partial(result, gaps)
49
+ return finalize(result, services, query)
50
+ except NormalizationError as exc:
51
+ return failed(
52
+ exc.code,
53
+ request_target,
54
+ endpoint=exc.endpoint_id if isinstance(exc, EndpointReadError) else None,
55
+ http_status=exc.http_status if isinstance(exc, EndpointReadError) else None,
56
+ )
57
+ except TimeoutError:
58
+ return failed("TARGET_TIMEOUT", request_target)
59
+ except (ValueError, TypeError, KeyError, RecursionError, UnicodeError):
60
+ return failed("MALFORMED_EVIDENCE", request_target)
61
+ except Exception:
62
+ return failed("INTERNAL_ERROR", request_target)
@@ -0,0 +1 @@
1
+ """Explicit reader interfaces; no live network connector is enabled."""