pretensor 0.1.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 (198) hide show
  1. pretensor/__init__.py +50 -0
  2. pretensor/benchmark/__init__.py +54 -0
  3. pretensor/benchmark/cli.py +294 -0
  4. pretensor/benchmark/fixtures.py +84 -0
  5. pretensor/benchmark/l1/__init__.py +23 -0
  6. pretensor/benchmark/l1/metrics.py +141 -0
  7. pretensor/benchmark/l1/pipeline.py +188 -0
  8. pretensor/benchmark/l1/runner.py +245 -0
  9. pretensor/benchmark/l2/__init__.py +27 -0
  10. pretensor/benchmark/l2/gold.py +236 -0
  11. pretensor/benchmark/l2/metrics.py +146 -0
  12. pretensor/benchmark/l2/pipeline.py +124 -0
  13. pretensor/benchmark/l2/runner.py +530 -0
  14. pretensor/benchmark/l3/__init__.py +73 -0
  15. pretensor/benchmark/l3/agent.py +316 -0
  16. pretensor/benchmark/l3/db.py +188 -0
  17. pretensor/benchmark/l3/gold.py +85 -0
  18. pretensor/benchmark/l3/llm_client.py +395 -0
  19. pretensor/benchmark/l3/mcp_client.py +357 -0
  20. pretensor/benchmark/l3/pretensor_runner.py +456 -0
  21. pretensor/benchmark/l3/prompt.py +132 -0
  22. pretensor/benchmark/l3/runner.py +358 -0
  23. pretensor/benchmark/l3/sql_equivalence.py +176 -0
  24. pretensor/benchmark/release_gate.py +448 -0
  25. pretensor/benchmark/results.py +298 -0
  26. pretensor/benchmark/runner.py +109 -0
  27. pretensor/cli/__init__.py +1 -0
  28. pretensor/cli/commands/_source_runner.py +147 -0
  29. pretensor/cli/commands/analyze.py +201 -0
  30. pretensor/cli/commands/connections/__init__.py +7 -0
  31. pretensor/cli/commands/connections/add_remove.py +126 -0
  32. pretensor/cli/commands/connections/register.py +12 -0
  33. pretensor/cli/commands/export.py +131 -0
  34. pretensor/cli/commands/index.py +559 -0
  35. pretensor/cli/commands/list.py +76 -0
  36. pretensor/cli/commands/quickstart.py +207 -0
  37. pretensor/cli/commands/reindex.py +646 -0
  38. pretensor/cli/commands/semantic.py +190 -0
  39. pretensor/cli/commands/serve.py +144 -0
  40. pretensor/cli/commands/sync_grants.py +149 -0
  41. pretensor/cli/commands/validate.py +176 -0
  42. pretensor/cli/config_file.py +442 -0
  43. pretensor/cli/constants.py +10 -0
  44. pretensor/cli/dbt_enrichment.py +96 -0
  45. pretensor/cli/main.py +109 -0
  46. pretensor/cli/paths.py +43 -0
  47. pretensor/cli/plugin.py +52 -0
  48. pretensor/config.py +226 -0
  49. pretensor/connectors/__init__.py +29 -0
  50. pretensor/connectors/base.py +165 -0
  51. pretensor/connectors/bigquery.py +468 -0
  52. pretensor/connectors/inspect.py +321 -0
  53. pretensor/connectors/lineage_sqlglot.py +97 -0
  54. pretensor/connectors/models.py +130 -0
  55. pretensor/connectors/mysql.py +402 -0
  56. pretensor/connectors/pg_array_parse.py +53 -0
  57. pretensor/connectors/postgres.py +938 -0
  58. pretensor/connectors/registry.py +93 -0
  59. pretensor/connectors/snapshot.py +244 -0
  60. pretensor/connectors/snowflake.py +908 -0
  61. pretensor/core/__init__.py +1 -0
  62. pretensor/core/builder.py +307 -0
  63. pretensor/core/dsn_crypto.py +51 -0
  64. pretensor/core/graph_schema_manager.py +246 -0
  65. pretensor/core/graph_store.py +1226 -0
  66. pretensor/core/ids.py +101 -0
  67. pretensor/core/portable_export.py +276 -0
  68. pretensor/core/query_runner.py +67 -0
  69. pretensor/core/registry.py +209 -0
  70. pretensor/core/schema.py +473 -0
  71. pretensor/core/secure_io.py +93 -0
  72. pretensor/core/store.py +469 -0
  73. pretensor/enrichment/__init__.py +1 -0
  74. pretensor/enrichment/analyze/__init__.py +0 -0
  75. pretensor/enrichment/analyze/classify.py +49 -0
  76. pretensor/enrichment/analyze/extract_python.py +196 -0
  77. pretensor/enrichment/analyze/parse.py +141 -0
  78. pretensor/enrichment/analyze/pipeline.py +195 -0
  79. pretensor/enrichment/analyze/summary.py +38 -0
  80. pretensor/enrichment/analyze/walker.py +98 -0
  81. pretensor/enrichment/analyze/writers.py +214 -0
  82. pretensor/enrichment/dbt/__init__.py +30 -0
  83. pretensor/enrichment/dbt/lineage.py +100 -0
  84. pretensor/enrichment/dbt/manifest.py +300 -0
  85. pretensor/enrichment/dbt/metadata.py +263 -0
  86. pretensor/enrichment/dbt/pipeline.py +77 -0
  87. pretensor/enrichment/dbt/resolution.py +101 -0
  88. pretensor/enrichment/dbt/signals.py +305 -0
  89. pretensor/entities/__init__.py +27 -0
  90. pretensor/entities/builder.py +63 -0
  91. pretensor/entities/classifier.py +383 -0
  92. pretensor/entities/llm_extract.py +66 -0
  93. pretensor/errors.py +35 -0
  94. pretensor/graph_models/__init__.py +17 -0
  95. pretensor/graph_models/base.py +11 -0
  96. pretensor/graph_models/consumer.py +71 -0
  97. pretensor/graph_models/edge.py +35 -0
  98. pretensor/graph_models/entity.py +21 -0
  99. pretensor/graph_models/node.py +79 -0
  100. pretensor/graph_models/relationship.py +33 -0
  101. pretensor/integrations/__init__.py +42 -0
  102. pretensor/integrations/_base.py +138 -0
  103. pretensor/integrations/google_adk.py +49 -0
  104. pretensor/integrations/langchain.py +55 -0
  105. pretensor/integrations/llamaindex.py +53 -0
  106. pretensor/intelligence/__init__.py +33 -0
  107. pretensor/intelligence/cluster_labeler.py +425 -0
  108. pretensor/intelligence/clustering.py +168 -0
  109. pretensor/intelligence/combining.py +32 -0
  110. pretensor/intelligence/discovery.py +114 -0
  111. pretensor/intelligence/embeddings.py +317 -0
  112. pretensor/intelligence/graph_export.py +200 -0
  113. pretensor/intelligence/heuristic.py +544 -0
  114. pretensor/intelligence/join_paths/__init__.py +130 -0
  115. pretensor/intelligence/join_paths/on_demand.py +516 -0
  116. pretensor/intelligence/join_paths/storage.py +70 -0
  117. pretensor/intelligence/llm_infer.py +78 -0
  118. pretensor/intelligence/llm_runtime.py +62 -0
  119. pretensor/intelligence/metric_templates.py +193 -0
  120. pretensor/intelligence/pipeline.py +364 -0
  121. pretensor/intelligence/role_exemplars.py +263 -0
  122. pretensor/intelligence/schema_classification.py +360 -0
  123. pretensor/intelligence/scoring.py +76 -0
  124. pretensor/intelligence/semantic.py +240 -0
  125. pretensor/intelligence/shadow_alias.py +101 -0
  126. pretensor/intelligence/statistical.py +50 -0
  127. pretensor/intelligence/steps.py +191 -0
  128. pretensor/intelligence/steps_embedding.py +168 -0
  129. pretensor/introspection/__init__.py +6 -0
  130. pretensor/introspection/inspector.py +5 -0
  131. pretensor/introspection/models/__init__.py +0 -0
  132. pretensor/introspection/models/base.py +5 -0
  133. pretensor/introspection/models/config.py +237 -0
  134. pretensor/introspection/models/dsn.py +550 -0
  135. pretensor/introspection/models/plan.py +116 -0
  136. pretensor/introspection/models/schema.py +10 -0
  137. pretensor/introspection/models/semantic.py +121 -0
  138. pretensor/introspection/models/validation.py +116 -0
  139. pretensor/introspection/snapshot.py +46 -0
  140. pretensor/mcp/__init__.py +16 -0
  141. pretensor/mcp/config_json.py +24 -0
  142. pretensor/mcp/payload_types.py +274 -0
  143. pretensor/mcp/resources/__init__.py +17 -0
  144. pretensor/mcp/resources/markdown.py +314 -0
  145. pretensor/mcp/server.py +285 -0
  146. pretensor/mcp/service.py +49 -0
  147. pretensor/mcp/service_context.py +142 -0
  148. pretensor/mcp/service_registry.py +294 -0
  149. pretensor/mcp/store_cache.py +43 -0
  150. pretensor/mcp/tool_registry.py +136 -0
  151. pretensor/mcp/tools/__init__.py +1 -0
  152. pretensor/mcp/tools/_rank.py +244 -0
  153. pretensor/mcp/tools/_timed.py +26 -0
  154. pretensor/mcp/tools/compile_metric.py +144 -0
  155. pretensor/mcp/tools/consumers.py +161 -0
  156. pretensor/mcp/tools/context.py +1121 -0
  157. pretensor/mcp/tools/cypher.py +509 -0
  158. pretensor/mcp/tools/detect_changes.py +254 -0
  159. pretensor/mcp/tools/impact.py +271 -0
  160. pretensor/mcp/tools/list.py +131 -0
  161. pretensor/mcp/tools/schema.py +170 -0
  162. pretensor/mcp/tools/search.py +316 -0
  163. pretensor/mcp/tools/semantic_search.py +282 -0
  164. pretensor/mcp/tools/traverse.py +1027 -0
  165. pretensor/mcp/tools/validate_sql.py +150 -0
  166. pretensor/observability.py +203 -0
  167. pretensor/py.typed +0 -0
  168. pretensor/quickstart/README.md +29 -0
  169. pretensor/quickstart/__init__.py +6 -0
  170. pretensor/quickstart/docker-compose.yml +18 -0
  171. pretensor/quickstart/pagila_data.sql +63 -0
  172. pretensor/quickstart/pagila_ddl.sql +92 -0
  173. pretensor/search/__init__.py +6 -0
  174. pretensor/search/base.py +80 -0
  175. pretensor/search/index.py +435 -0
  176. pretensor/semantic/__init__.py +24 -0
  177. pretensor/semantic/base.py +123 -0
  178. pretensor/semantic/compiler.py +487 -0
  179. pretensor/semantic/yaml_layer.py +180 -0
  180. pretensor/skills/__init__.py +5 -0
  181. pretensor/skills/generator.py +235 -0
  182. pretensor/staleness/__init__.py +15 -0
  183. pretensor/staleness/graph_patcher.py +355 -0
  184. pretensor/staleness/impact_analyzer.py +162 -0
  185. pretensor/staleness/snapshot_store.py +38 -0
  186. pretensor/validation/__init__.py +9 -0
  187. pretensor/validation/query_validator.py +436 -0
  188. pretensor/visibility/__init__.py +23 -0
  189. pretensor/visibility/config.py +126 -0
  190. pretensor/visibility/filter.py +143 -0
  191. pretensor/visibility/kuzu_helpers.py +32 -0
  192. pretensor/visibility/runtime.py +36 -0
  193. pretensor/visibility/sync_grants.py +188 -0
  194. pretensor-0.1.0.dist-info/METADATA +251 -0
  195. pretensor-0.1.0.dist-info/RECORD +198 -0
  196. pretensor-0.1.0.dist-info/WHEEL +4 -0
  197. pretensor-0.1.0.dist-info/entry_points.txt +2 -0
  198. pretensor-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,357 @@
1
+ """Sync wrapper around the MCP stdio client for the L3 pretensor runner.
2
+
3
+ The MCP Python SDK (``mcp``) is async-only — :class:`mcp.ClientSession`
4
+ exposes coroutines for ``initialize``, ``list_tools``, and ``call_tool``,
5
+ and the stdio transport is an async context manager. The L3 pretensor
6
+ runner is otherwise sync (mirrors the baseline runner shape and the
7
+ ``BenchmarkResult`` writer), so we host one event loop in a daemon
8
+ worker thread and forward every operation through it.
9
+
10
+ Subprocess ownership lives in the SDK's :func:`stdio_client` context
11
+ manager: entering the context spawns ``pretensor serve …`` and pipes
12
+ its stdio; exiting sends EOF, waits for shutdown, and reaps the
13
+ subprocess. All paths through :class:`StdioMcpClient` go through
14
+ ``__exit__`` even on test failure, so AC #6 (no orphan processes) is
15
+ held by the SDK rather than by hand-rolled signal handling.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import json
22
+ import threading
23
+ import warnings
24
+ from contextlib import AsyncExitStack
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from types import TracebackType
28
+ from typing import Any, Protocol
29
+
30
+ from mcp.client.stdio import StdioServerParameters, stdio_client
31
+ from mcp.types import CallToolResult
32
+ from mcp.types import Tool as McpTool
33
+
34
+ from mcp import ClientSession
35
+ from pretensor.benchmark.l3.agent import AgentTool
36
+ from pretensor.errors import PretensorError
37
+
38
+ __all__ = [
39
+ "McpClient",
40
+ "McpClientError",
41
+ "McpToolResult",
42
+ "StdioMcpClient",
43
+ "tool_result_to_text",
44
+ ]
45
+
46
+
47
+ _DEFAULT_STARTUP_TIMEOUT_S = 30.0
48
+ """How long to wait for ``pretensor serve`` to respond to ``initialize``.
49
+
50
+ The serve handshake is fast on cold-cache machines; 30s is a generous
51
+ upper bound. A longer-than-30s startup almost always means a broken
52
+ graph dir or a version mismatch — surface those as clear errors rather
53
+ than waiting forever.
54
+ """
55
+
56
+ _DEFAULT_TEARDOWN_TIMEOUT_S = 30.0
57
+ """How long to wait for the async session close + worker-thread join.
58
+
59
+ Distinct from :data:`_DEFAULT_STARTUP_TIMEOUT_S` so the two intents
60
+ read clearly even though the numeric values match today. ``stdio_client``
61
+ sends EOF and waits for the subprocess to exit; if the child hangs,
62
+ this cap keeps shutdown bounded.
63
+
64
+ Worst-case shutdown wall time is roughly **2x** this value:
65
+ :meth:`StdioMcpClient.__exit__` waits up to ``_DEFAULT_TEARDOWN_TIMEOUT_S``
66
+ for ``_async_close`` to finish, then ``_tear_down_loop`` joins the
67
+ worker thread for another ``_DEFAULT_TEARDOWN_TIMEOUT_S``. Both budgets
68
+ only get fully consumed when the subprocess refuses to exit AND the
69
+ loop thread itself is wedged — a real fault, not a normal slow run.
70
+ """
71
+
72
+ _DEFAULT_OUTER_LOOP_TIMEOUT_S = 90.0
73
+ """Outer guard on the ``__enter__`` wait for the worker loop's response.
74
+
75
+ ``asyncio.wait_for`` inside the loop already enforces
76
+ :data:`_DEFAULT_STARTUP_TIMEOUT_S`, so a healthy run never approaches
77
+ this bound. The guard exists to bound the main-thread wait when the
78
+ worker loop thread itself crashes (e.g. an unhandled exception in
79
+ ``_run_loop``) — without it, ``concurrent.futures.Future.result()``
80
+ would block the caller indefinitely. 90s is generous: it's long
81
+ enough to ride out the inner timeout plus its cleanup, and short
82
+ enough that an operator notices when it fires.
83
+ """
84
+
85
+ _DEFAULT_CALL_TIMEOUT_S = 60.0
86
+ """Per-tool-call timeout submitted to ``ClientSession.call_tool``.
87
+
88
+ Some tools (``cypher`` over a 100k-node graph, ``traverse`` with high
89
+ ``k``) take real time; the L3 runner already caps the LLM budget per
90
+ question elsewhere. 60s keeps any one stuck call from blocking the
91
+ whole question without short-circuiting healthy slow tools.
92
+ """
93
+
94
+
95
+ class McpClientError(PretensorError, RuntimeError):
96
+ """Raised on subprocess startup, shutdown, or call-level failures."""
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class McpToolResult:
101
+ """One MCP tool invocation's output, normalised for the agent loop.
102
+
103
+ ``content`` is what we hand the LLM (a JSON-encoded snapshot of the
104
+ structured result, or the concatenated text blocks if the tool
105
+ returned plain text). ``is_error`` flags MCP-level failures so the
106
+ runner can record a tool-call trace entry even when the call faulted.
107
+ """
108
+
109
+ content: str
110
+ is_error: bool
111
+
112
+
113
+ class McpClient(Protocol):
114
+ """Sync MCP surface used by the L3 pretensor runner.
115
+
116
+ Production callers use :class:`StdioMcpClient` (spawns
117
+ ``pretensor serve`` and speaks stdio). Tests inject a fake.
118
+ Both ``list_tools`` and ``call_tool`` are sync because the runner
119
+ drives one tool at a time per question; concurrency would not
120
+ produce a faster benchmark.
121
+ """
122
+
123
+ def list_tools(self) -> list[AgentTool]: ...
124
+
125
+ def call_tool(self, name: str, arguments: dict[str, Any]) -> McpToolResult: ...
126
+
127
+
128
+ class StdioMcpClient:
129
+ """Spawn-and-drive a ``pretensor serve`` MCP subprocess over stdio.
130
+
131
+ Use as a context manager (``with StdioMcpClient(...) as client``):
132
+ enter starts the subprocess, runs the MCP handshake, and stages the
133
+ background event loop; exit tears down the session, signals EOF on
134
+ stdio, and joins the worker thread. Cleanup is in a try/finally
135
+ inside the SDK so a tool-call exception does not leak the subprocess.
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ *,
141
+ graph_dir: Path,
142
+ command: str = "pretensor",
143
+ extra_args: list[str] | None = None,
144
+ env: dict[str, str] | None = None,
145
+ ) -> None:
146
+ self._params = StdioServerParameters(
147
+ command=command,
148
+ args=["serve", "--graph-dir", str(graph_dir), "--no-print-config"]
149
+ + list(extra_args or []),
150
+ env=env,
151
+ )
152
+ self._loop: asyncio.AbstractEventLoop | None = None
153
+ self._loop_thread: threading.Thread | None = None
154
+ self._session: ClientSession | None = None
155
+ self._exit_stack: AsyncExitStack | None = None
156
+ self._cached_tools: list[AgentTool] | None = None
157
+
158
+ def __enter__(self) -> StdioMcpClient:
159
+ self._loop = asyncio.new_event_loop()
160
+
161
+ def _run_loop() -> None:
162
+ assert self._loop is not None
163
+ asyncio.set_event_loop(self._loop)
164
+ self._loop.run_forever()
165
+
166
+ self._loop_thread = threading.Thread(
167
+ target=_run_loop, name="l3-mcp-stdio-loop", daemon=True
168
+ )
169
+ self._loop_thread.start()
170
+ # Push the startup timeout INSIDE the loop via ``asyncio.wait_for``.
171
+ # If the inner coroutine is suspended (e.g. ``stdio_client``
172
+ # spawned the child but ``initialize`` hasn't returned yet),
173
+ # ``wait_for`` cancels it cleanly: the task receives
174
+ # ``CancelledError`` at its next ``await``, ``_async_open``'s
175
+ # ``except BaseException: await stack.aclose()`` branch runs to
176
+ # completion (which calls ``stdio_client.__aexit__`` and reaps
177
+ # the subprocess), and only THEN is ``TimeoutError`` re-raised.
178
+ # An outer ``concurrent.futures`` timeout would mark the
179
+ # destination future cancelled and return immediately, leaving
180
+ # the source task to run its cleanup after the loop had already
181
+ # been torn down — orphaning the subprocess.
182
+ try:
183
+ asyncio.run_coroutine_threadsafe(
184
+ _wait_for_open(self, _DEFAULT_STARTUP_TIMEOUT_S), self._loop
185
+ ).result(timeout=_DEFAULT_OUTER_LOOP_TIMEOUT_S)
186
+ except Exception as exc:
187
+ self._tear_down_loop()
188
+ raise McpClientError(
189
+ f"Failed to start MCP subprocess `{self._params.command} "
190
+ f"{' '.join(self._params.args)}`: {exc}"
191
+ ) from exc
192
+ return self
193
+
194
+ def __exit__(
195
+ self,
196
+ exc_type: type[BaseException] | None,
197
+ exc: BaseException | None,
198
+ tb: TracebackType | None,
199
+ ) -> None:
200
+ if self._loop is None:
201
+ return
202
+ try:
203
+ asyncio.run_coroutine_threadsafe(self._async_close(), self._loop).result(
204
+ timeout=_DEFAULT_TEARDOWN_TIMEOUT_S
205
+ )
206
+ except Exception as cleanup_exc:
207
+ # ``AsyncExitStack.aclose()`` propagates the FIRST inner
208
+ # exception but still attempts to close every registered
209
+ # context — so ``stdio_client.__aexit__`` will normally
210
+ # have run and the subprocess will be reaped even when
211
+ # this branch fires. The exception we trap here is that
212
+ # propagated inner exception, OR a TimeoutError if the
213
+ # whole close took longer than the teardown budget.
214
+ #
215
+ # We don't re-raise: an exception in the original ``with``
216
+ # body would otherwise be silently shadowed by a cleanup
217
+ # error. We DO surface it as a warning so the run is not
218
+ # silent — the runner is non-interactive and a subprocess
219
+ # leak (e.g. a child that ignored EOF) would otherwise hide
220
+ # here. Operators see the warning on stderr and can
221
+ # investigate before the next run.
222
+ warnings.warn(
223
+ f"StdioMcpClient teardown error (subprocess may be "
224
+ f"orphaned if it did not exit on EOF): {cleanup_exc!r}",
225
+ ResourceWarning,
226
+ stacklevel=2,
227
+ )
228
+ finally:
229
+ self._tear_down_loop()
230
+
231
+ async def _async_open(self) -> None:
232
+ stack = AsyncExitStack()
233
+ try:
234
+ read, write = await stack.enter_async_context(stdio_client(self._params))
235
+ session = await stack.enter_async_context(ClientSession(read, write))
236
+ await session.initialize()
237
+ except BaseException:
238
+ await stack.aclose()
239
+ raise
240
+ self._session = session
241
+ self._exit_stack = stack
242
+
243
+ async def _async_close(self) -> None:
244
+ if self._exit_stack is not None:
245
+ await self._exit_stack.aclose()
246
+ self._exit_stack = None
247
+ self._session = None
248
+
249
+ def _tear_down_loop(self) -> None:
250
+ if self._loop is not None and self._loop.is_running():
251
+ self._loop.call_soon_threadsafe(self._loop.stop)
252
+ if self._loop_thread is not None:
253
+ self._loop_thread.join(timeout=_DEFAULT_TEARDOWN_TIMEOUT_S)
254
+ if self._loop is not None:
255
+ self._loop.close()
256
+ self._loop = None
257
+ self._loop_thread = None
258
+
259
+ def list_tools(self) -> list[AgentTool]:
260
+ """Return the full tool catalogue advertised by ``pretensor serve``.
261
+
262
+ The first call hits the server; subsequent calls return a
263
+ cached list — the OSS server does not register tools after
264
+ startup, so caching is safe and avoids repeating the round-trip
265
+ once per question.
266
+ """
267
+ if self._cached_tools is not None:
268
+ return list(self._cached_tools)
269
+ if self._session is None or self._loop is None:
270
+ raise McpClientError(
271
+ "StdioMcpClient.list_tools called outside the with-block "
272
+ "(or after an open() failure)."
273
+ )
274
+ result = asyncio.run_coroutine_threadsafe(
275
+ self._session.list_tools(), self._loop
276
+ ).result(timeout=_DEFAULT_CALL_TIMEOUT_S)
277
+ tools = [_to_agent_tool(t) for t in result.tools]
278
+ self._cached_tools = tools
279
+ return list(tools)
280
+
281
+ def call_tool(self, name: str, arguments: dict[str, Any]) -> McpToolResult:
282
+ """Invoke one MCP tool synchronously; surface server errors as ``is_error``."""
283
+ if self._session is None or self._loop is None:
284
+ raise McpClientError(
285
+ "StdioMcpClient.call_tool called outside the with-block "
286
+ "(or after an open() failure)."
287
+ )
288
+ future = asyncio.run_coroutine_threadsafe(
289
+ self._session.call_tool(name, arguments=arguments), self._loop
290
+ )
291
+ try:
292
+ result = future.result(timeout=_DEFAULT_CALL_TIMEOUT_S)
293
+ except Exception as exc:
294
+ # Build the error payload via ``json.dumps`` so an exception
295
+ # message containing quotes, backslashes, or newlines escapes
296
+ # cleanly — a raw f-string would emit invalid JSON the LLM
297
+ # tool-result decoder then misparses.
298
+ return McpToolResult(
299
+ content=json.dumps({"error": f"MCP transport failure: {exc}"}),
300
+ is_error=True,
301
+ )
302
+ return McpToolResult(
303
+ content=tool_result_to_text(result),
304
+ is_error=bool(result.isError),
305
+ )
306
+
307
+
308
+ async def _wait_for_open(client: StdioMcpClient, timeout_s: float) -> None:
309
+ """Run ``client._async_open`` under an in-loop ``asyncio.wait_for``.
310
+
311
+ Module-level (not a method) so the patching pattern used by tests
312
+ — ``monkeypatch.setattr(StdioMcpClient, "_async_open", fake)`` — is
313
+ picked up via the bound method lookup at call time. Lives next to
314
+ the class because it is intimate with its lifecycle.
315
+ """
316
+ await asyncio.wait_for(client._async_open(), timeout=timeout_s)
317
+
318
+
319
+ def _to_agent_tool(tool: McpTool) -> AgentTool:
320
+ """Down-cast :class:`mcp.types.Tool` into the L3-local representation.
321
+
322
+ MCP's ``Tool.description`` is optional; substituting an empty string
323
+ keeps the LLM payload well-formed without hiding a missing description
324
+ behind a placeholder that might get echoed back.
325
+ """
326
+ return AgentTool(
327
+ name=tool.name,
328
+ description=tool.description or "",
329
+ input_schema=dict(tool.inputSchema or {}),
330
+ )
331
+
332
+
333
+ def tool_result_to_text(result: CallToolResult) -> str:
334
+ """Flatten an MCP :class:`CallToolResult` into a single string for the LLM.
335
+
336
+ Preference order:
337
+ 1. ``structuredContent`` — JSON-encode it. The pretensor server
338
+ returns dict payloads through this channel; JSON is the cheapest
339
+ lossless rendering for the LLM.
340
+ 2. Concatenated ``text`` content blocks. Other content types
341
+ (image, audio, resource link) are skipped — the OSS server does
342
+ not produce them today.
343
+
344
+ A result with neither structured content nor text blocks is rendered
345
+ as an empty JSON object so the LLM still sees a well-formed tool
346
+ response.
347
+ """
348
+ if result.structuredContent is not None:
349
+ return json.dumps(result.structuredContent, sort_keys=True, default=str)
350
+ parts: list[str] = []
351
+ for block in result.content or []:
352
+ text = getattr(block, "text", None)
353
+ if isinstance(text, str):
354
+ parts.append(text)
355
+ if parts:
356
+ return "".join(parts)
357
+ return "{}"