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,322 @@
1
+ """Official MCP SDK host over STDIO. Tool calls stay on the read-only path."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import sys
8
+ import time
9
+ from dataclasses import replace
10
+ from typing import Any, TextIO, cast
11
+
12
+ import mcp.types as types
13
+ from jsonschema import Draft202012Validator, FormatChecker
14
+ from jsonschema.exceptions import ValidationError
15
+ from mcp.server.lowlevel import Server
16
+ from mcp.server.stdio import stdio_server
17
+ from mcp.shared.exceptions import MCPError
18
+ from mcp.shared.message import SessionMessage
19
+
20
+ from . import __version__
21
+ from .contracts.definitions import list_tools
22
+ from .models.source_catalog import SourceCatalog
23
+ from .models.timestamps import parse_rfc3339
24
+ from .models.common import CALL_BUDGET, CallBudget, NormalizationError
25
+ from .policy.sanitization import SanitizerPipeline
26
+ from .policy.configuration import ConfigReadPolicy
27
+ from .snapshots import SnapshotStore
28
+ from .services import Services
29
+ from .diagnostics import failed
30
+ from .tools.registry import call_tool, validate_handlers
31
+ from .tools.support import TOOL_TIMEOUT_SECONDS
32
+ from .transport.actuator_http import ActuatorHttpTransport
33
+ from .transport.response_gate import ENDPOINTS, ResponseGateReader
34
+ from .transport.responses import serialize_response, tool_result
35
+ from .transport.responses import MAX_RESPONSE_BYTES, _check_request_id
36
+
37
+ MAX_FRAME_BYTES = 64 * 1024
38
+ SERVER_NAME = "spring-runtime-mcp"
39
+ SERVER_VERSION = __version__
40
+ # The SDK stamps this on every 2026-era result; the budget measurement includes
41
+ # it so a response that fits our cap also fits the stamped wire frame.
42
+ SERVER_INFO_META: dict[str, Any] = {
43
+ "io.modelcontextprotocol/serverInfo": {
44
+ "name": SERVER_NAME,
45
+ "version": SERVER_VERSION,
46
+ }
47
+ }
48
+ _FORMAT_CHECKER = FormatChecker()
49
+
50
+
51
+ def _is_rfc3339(value: object) -> bool:
52
+ return not isinstance(value, str) or parse_rfc3339(value) is not None
53
+
54
+
55
+ _FORMAT_CHECKER.checkers["date-time"] = (_is_rfc3339, ())
56
+
57
+
58
+ def build_services(
59
+ base_url: str,
60
+ target_id: str = "local",
61
+ timeout: float = 5.0,
62
+ *,
63
+ config_policy: ConfigReadPolicy | None = None,
64
+ source_catalog: SourceCatalog | None = None,
65
+ bearer_token: str | None = None,
66
+ ) -> Services:
67
+ """Assemble the real reader. No connection is opened until a tool calls it."""
68
+ targets = frozenset({target_id})
69
+ transport = ActuatorHttpTransport(
70
+ base_url, targets=targets, bearer_token=bearer_token
71
+ )
72
+ reader = ResponseGateReader(
73
+ transport,
74
+ targets=targets,
75
+ enabled_endpoints=ENDPOINTS - {"env", "configprops"}
76
+ if config_policy is None
77
+ else ENDPOINTS,
78
+ timeout=timeout,
79
+ )
80
+ if source_catalog is None:
81
+ # Missing workspace facts are recorded provenance, not absent code.
82
+ catalog = SourceCatalog(target_id, None, {}, (), ("SOURCE_CATALOG_UNPROVIDED",))
83
+ elif source_catalog.target_id != target_id:
84
+ raise ValueError("SOURCE_TARGET_MISMATCH")
85
+ else:
86
+ catalog = source_catalog
87
+ return Services(
88
+ reader,
89
+ catalog,
90
+ SanitizerPipeline(
91
+ known_secrets=() if bearer_token is None else (bearer_token,)
92
+ ),
93
+ SnapshotStore(),
94
+ targets=targets,
95
+ config_policy=config_policy,
96
+ )
97
+
98
+
99
+ def _tool_models() -> list[types.Tool]:
100
+ return [types.Tool.model_validate(item, by_alias=True) for item in list_tools()]
101
+
102
+
103
+ def _valid_envelope(schema: dict[str, Any], envelope: object) -> bool:
104
+ if not isinstance(envelope, dict):
105
+ return False
106
+ try:
107
+ Draft202012Validator(schema, format_checker=_FORMAT_CHECKER).validate(envelope)
108
+ except ValidationError:
109
+ return False
110
+ return True
111
+
112
+
113
+ async def _call_tool(
114
+ ctx: Any, params: types.CallToolRequestParams, services: Services
115
+ ) -> types.CallToolResult:
116
+ name = params.name
117
+ catalog = {item["name"]: item for item in list_tools()}
118
+ arguments = params.arguments or {}
119
+ if name not in catalog:
120
+ # Unknown names are protocol errors, not tool execution results.
121
+ raise MCPError(-32602, "UNKNOWN_TOOL")
122
+ try:
123
+ Draft202012Validator(
124
+ catalog[name]["inputSchema"], format_checker=_FORMAT_CHECKER
125
+ ).validate(arguments)
126
+ except ValidationError:
127
+ return _result(failed("INVALID_ARGUMENT"), params_id(ctx))
128
+ try:
129
+ envelope = await call_tool(name, arguments, services)
130
+ if not _valid_envelope(catalog[name]["outputSchema"], envelope):
131
+ source = envelope.get("source") if isinstance(envelope, dict) else None
132
+ target = source.get("targetId") if isinstance(source, dict) else None
133
+ envelope = failed(
134
+ "OUTPUT_CONTRACT_VIOLATION",
135
+ target if isinstance(target, str) else "local",
136
+ )
137
+ except Exception:
138
+ envelope = failed("INTERNAL_ERROR")
139
+ return _result(envelope, params_id(ctx))
140
+
141
+
142
+ def params_id(ctx: Any) -> str | int | None:
143
+ request_id = getattr(ctx, "request_id", None)
144
+ return request_id if isinstance(request_id, (str, int)) else None
145
+
146
+
147
+ def _result(
148
+ envelope: dict[str, Any], request_id: str | int | None
149
+ ) -> types.CallToolResult:
150
+ fallback_id = request_id
151
+ try:
152
+ # Enforce the full-frame budget, server stamp included.
153
+ serialize_response(envelope, request_id, server_meta=SERVER_INFO_META)
154
+ except NormalizationError as exc:
155
+ # Preserve the real rejection reason; an unsafe/sanitization failure is
156
+ # not the same failure as an oversized frame. An invalid identity cannot
157
+ # be echoed, so the safe fallback uses a null id.
158
+ envelope = failed(exc.code)
159
+ if exc.code == "INVALID_REQUEST_ID":
160
+ fallback_id = None
161
+ serialize_response(envelope, fallback_id, server_meta=SERVER_INFO_META)
162
+ except Exception:
163
+ envelope = failed("INTERNAL_ERROR")
164
+ serialize_response(envelope, fallback_id, server_meta=SERVER_INFO_META)
165
+ return types.CallToolResult.model_validate(tool_result(envelope), by_alias=True)
166
+
167
+
168
+ def build_server(services: Services) -> Server[Services]:
169
+ validate_handlers()
170
+
171
+ async def list_tools_handler(
172
+ ctx: Any, params: types.PaginatedRequestParams | None
173
+ ) -> types.ListToolsResult:
174
+ # Re-publish each committed contract entry unchanged, outputSchema included.
175
+ return types.ListToolsResult(tools=_tool_models())
176
+
177
+ async def call_tool_handler(
178
+ ctx: Any, params: types.CallToolRequestParams
179
+ ) -> types.CallToolResult:
180
+ scope = CallBudget(time.monotonic() + TOOL_TIMEOUT_SECONDS)
181
+ token = CALL_BUDGET.set(scope)
182
+ try:
183
+ async with asyncio.timeout(scope.check()):
184
+ result = await _call_tool(ctx, params, services)
185
+ scope.check()
186
+ return result
187
+ except (TimeoutError, NormalizationError):
188
+ # Expired scopes cannot be used while serializing the safe fallback.
189
+ CALL_BUDGET.reset(token)
190
+ token = CALL_BUDGET.set(None)
191
+ return _result(failed("TARGET_TIMEOUT"), params_id(ctx))
192
+ finally:
193
+ CALL_BUDGET.reset(token)
194
+
195
+ return Server(
196
+ SERVER_NAME,
197
+ version=SERVER_VERSION,
198
+ on_list_tools=list_tools_handler,
199
+ on_call_tool=call_tool_handler,
200
+ )
201
+
202
+
203
+ class _BoundedLineReader:
204
+ """UTF-8 stdin lines capped at the frame limit, one byte over so overflow is visible."""
205
+
206
+ def __init__(self, binary: Any, limit: int = MAX_FRAME_BYTES) -> None:
207
+ self._binary = binary
208
+ self._limit = limit
209
+ self.oversized = False
210
+ self.invalid_identity = False
211
+
212
+ def readline(self, size: int = -1) -> str:
213
+ line = self._binary.readline(self._limit + 1)
214
+ if len(line) > self._limit:
215
+ self.oversized = True
216
+ return ""
217
+ decoded: str = line.decode("utf-8", errors="replace")
218
+ try:
219
+ message = json.loads(decoded)
220
+ identifier = message.get("id") if isinstance(message, dict) else None
221
+ if type(identifier) in (str, int):
222
+ _check_request_id(identifier)
223
+ except (ValueError, UnicodeError, RecursionError) as exc:
224
+ if isinstance(exc, NormalizationError):
225
+ self.invalid_identity = True
226
+ return ""
227
+ return decoded
228
+
229
+ async def read_line(self) -> str:
230
+ # The stdio transport awaits this; the bounded read stays off the loop.
231
+ return await asyncio.to_thread(self.readline)
232
+
233
+ def __aiter__(self) -> _BoundedLineReader:
234
+ return self
235
+
236
+ async def __anext__(self) -> str:
237
+ line = await self.read_line()
238
+ if not line:
239
+ raise StopAsyncIteration
240
+ return line
241
+
242
+
243
+ class _BoundedWriteStream:
244
+ """Check SDK-serialized tool frames at the last send boundary."""
245
+
246
+ def __init__(self, stream: Any) -> None:
247
+ self._stream = stream
248
+
249
+ async def __aenter__(self) -> _BoundedWriteStream:
250
+ await self._stream.__aenter__()
251
+ return self
252
+
253
+ async def aclose(self) -> None:
254
+ await self._stream.aclose()
255
+
256
+ async def __aexit__(self, *args: Any) -> None:
257
+ await self._stream.__aexit__(*args)
258
+
259
+ async def send(self, item: SessionMessage) -> None:
260
+ message = item.message
261
+ if (
262
+ isinstance(message, types.JSONRPCResponse)
263
+ and "structuredContent" in message.result
264
+ ):
265
+ size = len(
266
+ message.model_dump_json(by_alias=True, exclude_unset=True).encode(
267
+ "utf-8"
268
+ )
269
+ )
270
+ if size > MAX_RESPONSE_BYTES:
271
+ message = types.JSONRPCResponse(
272
+ jsonrpc="2.0",
273
+ id=message.id,
274
+ result=tool_result(
275
+ failed("RESPONSE_TOO_LARGE"), server_meta=SERVER_INFO_META
276
+ ),
277
+ )
278
+ _check_request_id(message.id)
279
+ if (
280
+ len(
281
+ message.model_dump_json(
282
+ by_alias=True, exclude_unset=True
283
+ ).encode("utf-8")
284
+ )
285
+ > MAX_RESPONSE_BYTES
286
+ ):
287
+ raise ValueError("RESPONSE_TOO_LARGE")
288
+ item = replace(item, message=message)
289
+ # The destination is the SDK's protocol stream, not a socket. Its stdio
290
+ # writer retains ownership of fd 1 and all JSON-RPC framing.
291
+ await self._stream.send(item)
292
+
293
+
294
+ async def run_stdio(services: Services, stdout: TextIO | None = None) -> None:
295
+ """Serve MCP over the process's stdin/stdout until EOF.
296
+
297
+ A frame over the limit is rejected once and ends the session, so its
298
+ remaining bytes cannot be re-read as later requests.
299
+ """
300
+ reader = _BoundedLineReader(sys.stdin.buffer)
301
+ server = build_server(services)
302
+ # The SDK accepts a text-receive stream; the bounded reader implements the
303
+ # async iteration and read_line contract used by stdio_server.
304
+ async with stdio_server(stdin=cast(Any, reader)) as (read_stream, write_stream):
305
+ await server.run(
306
+ read_stream,
307
+ _BoundedWriteStream(write_stream),
308
+ server.create_initialization_options(),
309
+ )
310
+ if reader.oversized or reader.invalid_identity:
311
+ frame = json.dumps(
312
+ {
313
+ "jsonrpc": "2.0",
314
+ "id": None,
315
+ "error": {"code": -32600, "message": "INVALID_REQUEST"},
316
+ },
317
+ separators=(",", ":"),
318
+ sort_keys=True,
319
+ )
320
+ channel = stdout if stdout is not None else sys.stdout
321
+ channel.write(frame + "\n")
322
+ channel.flush()
@@ -0,0 +1,44 @@
1
+ """Application services shared by all tools, with one target read coordinator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from dataclasses import dataclass, field
7
+ from typing import Protocol
8
+
9
+ from .models.common import NormalizationError, ParsedObservation, remaining_seconds
10
+ from .models.source_catalog import SourceCatalog
11
+ from .policy.configuration import ConfigReadPolicy
12
+ from .policy.sanitization import SanitizerPipeline
13
+ from .snapshots import SnapshotStore
14
+
15
+
16
+ class RuntimeReader(Protocol):
17
+ async def read(
18
+ self, target_id: str, endpoint_id: str, *, snapshot_id: str
19
+ ) -> ParsedObservation: ...
20
+
21
+
22
+ @dataclass
23
+ class Services:
24
+ reader: RuntimeReader
25
+ catalog: SourceCatalog
26
+ sanitizer: SanitizerPipeline
27
+ snapshots: SnapshotStore
28
+ targets: frozenset[str] = frozenset({"local"})
29
+ config_policy: ConfigReadPolicy | None = None
30
+ _read_slots: dict[str, asyncio.Semaphore] = field(default_factory=dict, init=False)
31
+
32
+ async def read(
33
+ self, target_id: str, endpoint_id: str, *, snapshot_id: str
34
+ ) -> ParsedObservation:
35
+ if target_id not in self.targets or self.catalog.target_id != target_id:
36
+ raise NormalizationError("TARGET_NOT_REGISTERED")
37
+ slot = self._read_slots.setdefault(target_id, asyncio.Semaphore(2))
38
+ # The outer tool deadline also includes waiting for this shared slot.
39
+ async with asyncio.timeout(remaining_seconds()):
40
+ async with slot:
41
+ return await asyncio.wait_for(
42
+ self.reader.read(target_id, endpoint_id, snapshot_id=snapshot_id),
43
+ timeout=remaining_seconds(5.0),
44
+ )
@@ -0,0 +1,340 @@
1
+ """Bounded in-memory cursor store; callers supply sanitized envelopes only."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import secrets
8
+ import time
9
+ from collections import OrderedDict
10
+ from collections.abc import Callable
11
+ from copy import deepcopy
12
+ from dataclasses import asdict, dataclass
13
+ from typing import Any
14
+
15
+ from .models.common import NormalizationError
16
+ from .models.queries import InspectBeansQuery
17
+ from .policy.output_guard import ensure_safe_output
18
+ from .policy.sanitization import SanitizerPipeline
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class _Entry:
23
+ data: bytes
24
+ query_digest: bytes
25
+ expires_at: float
26
+
27
+
28
+ @dataclass
29
+ class _PagedSnapshot:
30
+ header: bytes
31
+ items: tuple[bytes, ...]
32
+ query_digest: bytes
33
+ expires_at: float
34
+ size: int
35
+ policy: tuple[object, bytes] | None
36
+ references: int = 0
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class _PageEntry:
41
+ snapshot: _PagedSnapshot
42
+ offset: int
43
+
44
+ @property
45
+ def expires_at(self) -> float:
46
+ return self.snapshot.expires_at
47
+
48
+
49
+ def page_envelope(envelope: dict[str, Any], items: list[Any]) -> dict[str, Any]:
50
+ """Copy only page metadata and selected records, never the unsent tail."""
51
+ result = {key: deepcopy(value) for key, value in envelope.items() if key != "data"}
52
+ result["data"] = {
53
+ key: deepcopy(value)
54
+ for key, value in envelope["data"].items()
55
+ if key != "items"
56
+ }
57
+ result["data"]["items"] = deepcopy(items)
58
+ return result
59
+
60
+
61
+ class SnapshotStore:
62
+ """Opaque cursor handles bind a frozen result to all non-cursor arguments."""
63
+
64
+ def __init__(
65
+ self,
66
+ clock: Callable[[], float] = time.monotonic,
67
+ *,
68
+ ttl: float = 60.0,
69
+ max_bytes: int = 32 * 1024 * 1024,
70
+ ) -> None:
71
+ if not 0 < ttl <= 60 or not 0 < max_bytes <= 32 * 1024 * 1024:
72
+ raise ValueError("INVALID_SNAPSHOT_LIMITS")
73
+ self._clock = clock
74
+ self._ttl = ttl
75
+ self._max_bytes = max_bytes
76
+ self._size = 0
77
+ self._entries: OrderedDict[str, _Entry | _PageEntry] = OrderedDict()
78
+
79
+ @staticmethod
80
+ def _digest(query: InspectBeansQuery) -> bytes:
81
+ values = asdict(query)
82
+ values.pop("cursor", None)
83
+ encoded = json.dumps(
84
+ values,
85
+ sort_keys=True,
86
+ ensure_ascii=False,
87
+ separators=(",", ":"),
88
+ allow_nan=False,
89
+ ).encode("utf-8")
90
+ return hashlib.sha256(encoded).digest()
91
+
92
+ def _remove(self, token: str) -> None:
93
+ entry = self._entries.pop(token)
94
+ if isinstance(entry, _Entry):
95
+ self._size -= len(entry.data)
96
+ else:
97
+ self._size -= 256
98
+ entry.snapshot.references -= 1
99
+ if entry.snapshot.references == 0:
100
+ self._size -= entry.snapshot.size
101
+
102
+ def _prune(self) -> None:
103
+ now = self._clock()
104
+ for token in tuple(self._entries):
105
+ if self._entries[token].expires_at <= now:
106
+ self._remove(token)
107
+
108
+ def _token(self, sanitizer: SanitizerPipeline | None) -> str:
109
+ for _ in range(16):
110
+ token = secrets.token_urlsafe(32)
111
+ if token in self._entries:
112
+ continue
113
+ try:
114
+ ensure_safe_output({"nextCursor": token}, sanitizer=sanitizer)
115
+ except NormalizationError as exc:
116
+ if exc.code not in {"UNSAFE_OUTPUT", "UNTRUSTED_OUTPUT"}:
117
+ raise
118
+ continue
119
+ return token
120
+ raise NormalizationError("UNSAFE_OUTPUT")
121
+
122
+ def put(
123
+ self,
124
+ envelope: dict[str, Any],
125
+ query: InspectBeansQuery,
126
+ *,
127
+ sanitizer: SanitizerPipeline | None = None,
128
+ expires_at: float | None = None,
129
+ ) -> str:
130
+ self._prune()
131
+ deadline = self._clock() + self._ttl
132
+ if expires_at is not None:
133
+ deadline = min(deadline, expires_at)
134
+ if deadline <= self._clock():
135
+ raise NormalizationError("CURSOR_EXPIRED")
136
+ ensure_safe_output(envelope, sanitizer=sanitizer)
137
+ try:
138
+ data = json.dumps(
139
+ envelope,
140
+ sort_keys=True,
141
+ ensure_ascii=False,
142
+ separators=(",", ":"),
143
+ allow_nan=False,
144
+ ).encode("utf-8")
145
+ except (TypeError, ValueError, UnicodeError):
146
+ raise NormalizationError("INVALID_RESPONSE") from None
147
+ if len(data) > self._max_bytes:
148
+ raise NormalizationError("SNAPSHOT_TOO_LARGE")
149
+ token = self._token(sanitizer)
150
+ while self._entries and self._size + len(data) > self._max_bytes:
151
+ self._remove(next(iter(self._entries)))
152
+ self._entries[token] = _Entry(data, self._digest(query), deadline)
153
+ self._size += len(data)
154
+ return token
155
+
156
+ def deadline(self, token: str, query: InspectBeansQuery) -> float:
157
+ """Continuations inherit the original observation's absolute expiry."""
158
+ self._prune()
159
+ entry = self._entries.get(token)
160
+ if entry is None:
161
+ raise NormalizationError("CURSOR_EXPIRED")
162
+ digest = (
163
+ entry.query_digest
164
+ if isinstance(entry, _Entry)
165
+ else entry.snapshot.query_digest
166
+ )
167
+ if not secrets.compare_digest(digest, self._digest(query)):
168
+ raise NormalizationError("CURSOR_QUERY_MISMATCH")
169
+ return entry.expires_at
170
+
171
+ def get(
172
+ self,
173
+ token: str,
174
+ query: InspectBeansQuery,
175
+ *,
176
+ sanitizer: SanitizerPipeline | None = None,
177
+ ) -> dict[str, Any]:
178
+ self._prune()
179
+ entry = self._entries.get(token)
180
+ if entry is None:
181
+ raise NormalizationError("CURSOR_EXPIRED")
182
+ digest = (
183
+ entry.query_digest
184
+ if isinstance(entry, _Entry)
185
+ else entry.snapshot.query_digest
186
+ )
187
+ if not secrets.compare_digest(digest, self._digest(query)):
188
+ raise NormalizationError("CURSOR_QUERY_MISMATCH")
189
+ self._entries.move_to_end(token)
190
+ if isinstance(entry, _PageEntry):
191
+ self._review(entry.snapshot, sanitizer)
192
+ paged = self._decode_page(entry, len(entry.snapshot.items))
193
+ ensure_safe_output(paged, sanitizer=sanitizer)
194
+ return paged
195
+ # Decoding produces a fresh mutable copy; callers cannot alter cached facts.
196
+ value: dict[str, Any] = json.loads(entry.data)
197
+ try:
198
+ ensure_safe_output(value, sanitizer=sanitizer)
199
+ except NormalizationError:
200
+ # A newly registered credential/policy may invalidate old facts.
201
+ # Evict rather than retaining a now-recognized secret in the cache.
202
+ self._remove(token)
203
+ raise
204
+ return value
205
+
206
+ def put_paged(
207
+ self,
208
+ envelope: dict[str, Any],
209
+ query: Any,
210
+ *,
211
+ offset: int,
212
+ sanitizer: SanitizerPipeline | None = None,
213
+ expires_at: float | None = None,
214
+ ) -> str:
215
+ """Freeze sanitized evidence once; continuations share encoded records."""
216
+ self._prune()
217
+ deadline = self._clock() + self._ttl
218
+ if expires_at is not None:
219
+ deadline = min(deadline, expires_at)
220
+ if deadline <= self._clock():
221
+ raise NormalizationError("CURSOR_EXPIRED")
222
+ ensure_safe_output(envelope, sanitizer=sanitizer)
223
+ items = envelope["data"]["items"]
224
+ if not 0 <= offset < len(items):
225
+ raise ValueError("INVALID_PAGE_OFFSET")
226
+
227
+ def encode(value: Any) -> bytes:
228
+ return json.dumps(
229
+ value,
230
+ sort_keys=True,
231
+ ensure_ascii=False,
232
+ separators=(",", ":"),
233
+ allow_nan=False,
234
+ ).encode("utf-8")
235
+
236
+ header = encode(page_envelope(envelope, []))
237
+ records = tuple(encode(item) for item in items)
238
+ size = len(header) + sum(len(item) + 32 for item in records) + 128
239
+ snapshot = _PagedSnapshot(
240
+ header,
241
+ records,
242
+ self._digest(query),
243
+ deadline,
244
+ size,
245
+ sanitizer.cache_revision if sanitizer is not None else None,
246
+ )
247
+ return self._register_page(snapshot, offset, sanitizer)
248
+
249
+ def _register_page(
250
+ self, snapshot: _PagedSnapshot, offset: int, sanitizer: SanitizerPipeline | None
251
+ ) -> str:
252
+ if snapshot.size + 256 > self._max_bytes:
253
+ raise NormalizationError("SNAPSHOT_TOO_LARGE")
254
+ token = self._token(sanitizer)
255
+ # Eviction may remove the last handle for this snapshot. Recompute its
256
+ # storage charge each iteration so continuation accounting stays exact.
257
+ while (
258
+ self._size + 256 + (snapshot.size if snapshot.references == 0 else 0)
259
+ > self._max_bytes
260
+ ):
261
+ self._remove(next(iter(self._entries)))
262
+ if snapshot.expires_at <= self._clock():
263
+ raise NormalizationError("CURSOR_EXPIRED")
264
+ if snapshot.references == 0:
265
+ self._size += snapshot.size
266
+ snapshot.references += 1
267
+ self._size += 256
268
+ self._entries[token] = _PageEntry(snapshot, offset)
269
+ return token
270
+
271
+ def _review(
272
+ self, snapshot: _PagedSnapshot, sanitizer: SanitizerPipeline | None
273
+ ) -> None:
274
+ # Replacement policies or changed credential variants must recheck ALL
275
+ # cached facts, including unrequested records. Cache only an opaque stamp.
276
+ revision = sanitizer.cache_revision if sanitizer is not None else None
277
+ if snapshot.policy == revision:
278
+ return
279
+ try:
280
+ ensure_safe_output(json.loads(snapshot.header), sanitizer=sanitizer)
281
+ for item in snapshot.items:
282
+ ensure_safe_output(json.loads(item), sanitizer=sanitizer)
283
+ except NormalizationError:
284
+ for token, entry in tuple(self._entries.items()):
285
+ if isinstance(entry, _PageEntry) and entry.snapshot is snapshot:
286
+ self._remove(token)
287
+ raise
288
+ snapshot.policy = revision
289
+
290
+ @staticmethod
291
+ def _decode_page(entry: _PageEntry, limit: int) -> dict[str, Any]:
292
+ result: dict[str, Any] = json.loads(entry.snapshot.header)
293
+ result["data"]["items"] = [
294
+ json.loads(item)
295
+ for item in entry.snapshot.items[entry.offset : entry.offset + limit]
296
+ ]
297
+ return result
298
+
299
+ def get_page(
300
+ self, token: str, query: Any, *, sanitizer: SanitizerPipeline | None = None
301
+ ) -> tuple[dict[str, Any], bool]:
302
+ self.deadline(token, query)
303
+ entry = self._entries[token]
304
+ self._entries.move_to_end(token)
305
+ if isinstance(entry, _Entry):
306
+ result = self.get(token, query, sanitizer=sanitizer)
307
+ items = result["data"]["items"]
308
+ return page_envelope(result, items[: query.max_results]), len(
309
+ items
310
+ ) > query.max_results
311
+ self._review(entry.snapshot, sanitizer)
312
+ result = self._decode_page(entry, query.max_results)
313
+ ensure_safe_output(result, sanitizer=sanitizer)
314
+ return result, entry.offset + query.max_results < len(entry.snapshot.items)
315
+
316
+ def advance(
317
+ self,
318
+ token: str,
319
+ query: Any,
320
+ consumed: int,
321
+ *,
322
+ sanitizer: SanitizerPipeline | None = None,
323
+ ) -> str:
324
+ self.deadline(token, query)
325
+ entry = self._entries[token]
326
+ if not 0 < consumed <= query.max_results:
327
+ raise ValueError("INVALID_PAGE_OFFSET")
328
+ if isinstance(entry, _Entry):
329
+ return self.put_paged(
330
+ self.get(token, query, sanitizer=sanitizer),
331
+ query,
332
+ offset=consumed,
333
+ sanitizer=sanitizer,
334
+ expires_at=entry.expires_at,
335
+ )
336
+ self._review(entry.snapshot, sanitizer)
337
+ offset = entry.offset + consumed
338
+ if offset >= len(entry.snapshot.items):
339
+ raise ValueError("INVALID_PAGE_OFFSET")
340
+ return self._register_page(entry.snapshot, offset, sanitizer)