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,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)
|