readyagentsdev 0.8.2__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.
- readyagents/__init__.py +38 -0
- readyagents/__main__.py +4 -0
- readyagents/audit.py +67 -0
- readyagents/cli.py +1050 -0
- readyagents/config.py +264 -0
- readyagents/errors.py +129 -0
- readyagents/llm/__init__.py +11 -0
- readyagents/llm/anthropic_provider.py +72 -0
- readyagents/llm/base.py +57 -0
- readyagents/llm/cache.py +86 -0
- readyagents/llm/openai_compat.py +12 -0
- readyagents/llm/openai_provider.py +70 -0
- readyagents/llm/registry.py +112 -0
- readyagents/llm/resilience.py +179 -0
- readyagents/llm/tool_calls.py +286 -0
- readyagents/logging.py +162 -0
- readyagents/mcp/__init__.py +43 -0
- readyagents/mcp/builtin.py +674 -0
- readyagents/mcp/client.py +253 -0
- readyagents/mcp/http.py +585 -0
- readyagents/mcp/run_api.py +1077 -0
- readyagents/mcp/server.py +246 -0
- readyagents/notify.py +63 -0
- readyagents/packs/__init__.py +26 -0
- readyagents/packs/loader.py +157 -0
- readyagents/packs/protocol.py +55 -0
- readyagents/policy.py +127 -0
- readyagents/py.typed +1 -0
- readyagents/report.py +88 -0
- readyagents/scaffold.py +410 -0
- readyagents/secrets.py +120 -0
- readyagents/testing/__init__.py +17 -0
- readyagents/testing/eval.py +219 -0
- readyagents/testing/helpers.py +128 -0
- readyagents/testing/recorded.py +68 -0
- readyagents/tools/__init__.py +67 -0
- readyagents/workflow/__init__.py +3 -0
- readyagents/workflow/cancellation.py +88 -0
- readyagents/workflow/conditions.py +279 -0
- readyagents/workflow/engine.py +354 -0
- readyagents/workflow/nodes.py +944 -0
- readyagents/workflow/runner.py +375 -0
- readyagents/workflow/schema.py +287 -0
- readyagents/workflow/state.py +472 -0
- readyagents/workflow/structured.py +103 -0
- readyagents/workflow/templates.py +125 -0
- readyagentsdev-0.8.2.dist-info/METADATA +215 -0
- readyagentsdev-0.8.2.dist-info/RECORD +51 -0
- readyagentsdev-0.8.2.dist-info/WHEEL +4 -0
- readyagentsdev-0.8.2.dist-info/entry_points.txt +2 -0
- readyagentsdev-0.8.2.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Connect to MCP servers declared in a workflow (optional extra)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
from contextlib import AsyncExitStack
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from readyagents.errors import MCPError
|
|
13
|
+
from readyagents.tools import FunctionTool, Tool
|
|
14
|
+
from readyagents.workflow.schema import MCPServerSpec
|
|
15
|
+
|
|
16
|
+
# Stdio children get a narrow env. API keys are not inherited unless the
|
|
17
|
+
# workflow sets them under mcp_servers.<name>.env.
|
|
18
|
+
_PASSTHROUGH_ENV = frozenset(
|
|
19
|
+
{
|
|
20
|
+
"PATH",
|
|
21
|
+
"HOME",
|
|
22
|
+
"LANG",
|
|
23
|
+
"LC_ALL",
|
|
24
|
+
"LC_CTYPE",
|
|
25
|
+
"LC_MESSAGES",
|
|
26
|
+
"TERM",
|
|
27
|
+
"TMPDIR",
|
|
28
|
+
"TEMP",
|
|
29
|
+
"TMP",
|
|
30
|
+
"USER",
|
|
31
|
+
"LOGNAME",
|
|
32
|
+
"SHELL",
|
|
33
|
+
}
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def mcp_child_env(spec: MCPServerSpec) -> dict[str, str]:
|
|
38
|
+
"""Env mapping passed to an MCP stdio subprocess."""
|
|
39
|
+
env = {key: value for key, value in os.environ.items() if key in _PASSTHROUGH_ENV}
|
|
40
|
+
if spec.env:
|
|
41
|
+
env.update(spec.env)
|
|
42
|
+
return env
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def mcp_available() -> bool:
|
|
46
|
+
try:
|
|
47
|
+
import mcp # noqa: F401
|
|
48
|
+
|
|
49
|
+
return True
|
|
50
|
+
except ImportError:
|
|
51
|
+
return False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _resolve_mcp_cwd(spec: MCPServerSpec, workspace: Path) -> Path:
|
|
55
|
+
"""Use the run workspace, or confine an explicit cwd under it, before spawn."""
|
|
56
|
+
root = Path(workspace).resolve()
|
|
57
|
+
raw = (spec.cwd or "").strip()
|
|
58
|
+
if not raw:
|
|
59
|
+
return root
|
|
60
|
+
from readyagents.workflow.runner import confine_under
|
|
61
|
+
|
|
62
|
+
return confine_under(raw, root, what="MCP cwd")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _schema_from_mcp_tool(item: Any) -> dict[str, Any]:
|
|
66
|
+
raw: Any = None
|
|
67
|
+
if isinstance(item, dict):
|
|
68
|
+
raw = item.get("inputSchema", item.get("input_schema"))
|
|
69
|
+
else:
|
|
70
|
+
raw = getattr(item, "inputSchema", None)
|
|
71
|
+
if raw is None:
|
|
72
|
+
raw = getattr(item, "input_schema", None)
|
|
73
|
+
if isinstance(raw, dict):
|
|
74
|
+
return dict(raw)
|
|
75
|
+
return {}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _result_text(result: Any) -> str:
|
|
79
|
+
content = getattr(result, "content", None)
|
|
80
|
+
if not content:
|
|
81
|
+
return str(result)
|
|
82
|
+
texts: list[str] = []
|
|
83
|
+
for block in content:
|
|
84
|
+
if isinstance(block, dict):
|
|
85
|
+
text = block.get("text")
|
|
86
|
+
else:
|
|
87
|
+
text = getattr(block, "text", None)
|
|
88
|
+
if text is not None:
|
|
89
|
+
texts.append(str(text))
|
|
90
|
+
return "\n".join(texts) if texts else str(result)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _AsyncLoop:
|
|
94
|
+
"""Background asyncio loop so MCP stdio sessions outlive a single coroutine."""
|
|
95
|
+
|
|
96
|
+
def __init__(self) -> None:
|
|
97
|
+
self.loop = asyncio.new_event_loop()
|
|
98
|
+
self._ready = threading.Event()
|
|
99
|
+
self._thread = threading.Thread(target=self._run, name="readyagents-mcp", daemon=True)
|
|
100
|
+
self._thread.start()
|
|
101
|
+
if not self._ready.wait(timeout=5):
|
|
102
|
+
raise MCPError("MCP event loop failed to start")
|
|
103
|
+
|
|
104
|
+
def _run(self) -> None:
|
|
105
|
+
asyncio.set_event_loop(self.loop)
|
|
106
|
+
self._ready.set()
|
|
107
|
+
self.loop.run_forever()
|
|
108
|
+
pending = asyncio.all_tasks(self.loop)
|
|
109
|
+
for task in pending:
|
|
110
|
+
task.cancel()
|
|
111
|
+
if pending:
|
|
112
|
+
self.loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
|
|
113
|
+
self.loop.close()
|
|
114
|
+
|
|
115
|
+
def run(self, coro: Any, *, timeout: float = 60) -> Any:
|
|
116
|
+
fut = asyncio.run_coroutine_threadsafe(coro, self.loop)
|
|
117
|
+
try:
|
|
118
|
+
return fut.result(timeout=timeout)
|
|
119
|
+
except TimeoutError:
|
|
120
|
+
fut.cancel()
|
|
121
|
+
raise MCPError("MCP operation timed out") from None
|
|
122
|
+
|
|
123
|
+
def stop(self) -> None:
|
|
124
|
+
if not self.loop.is_closed():
|
|
125
|
+
self.loop.call_soon_threadsafe(self.loop.stop)
|
|
126
|
+
self._thread.join(timeout=10)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class MCPClient:
|
|
130
|
+
"""Long-lived stdio MCP sessions, one child per ``mcp_servers`` name."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, servers: dict[str, MCPServerSpec], workspace: Path) -> None:
|
|
133
|
+
self._servers = dict(servers)
|
|
134
|
+
self._workspace = Path(workspace)
|
|
135
|
+
# Confine every declared cwd before any subprocess is spawned.
|
|
136
|
+
self._cwds = {
|
|
137
|
+
name: _resolve_mcp_cwd(spec, self._workspace) for name, spec in self._servers.items()
|
|
138
|
+
}
|
|
139
|
+
self._tools: dict[str, Tool] | None = None
|
|
140
|
+
self._sessions: dict[str, Any] = {}
|
|
141
|
+
self._stack: AsyncExitStack | None = None
|
|
142
|
+
self._loop: _AsyncLoop | None = None
|
|
143
|
+
self._lock = threading.Lock()
|
|
144
|
+
|
|
145
|
+
def tools(self) -> dict[str, Tool]:
|
|
146
|
+
if not self._servers:
|
|
147
|
+
return {}
|
|
148
|
+
with self._lock:
|
|
149
|
+
if self._tools is None:
|
|
150
|
+
self._tools = self._connect_locked()
|
|
151
|
+
return self._tools
|
|
152
|
+
|
|
153
|
+
def close(self) -> None:
|
|
154
|
+
with self._lock:
|
|
155
|
+
self._shutdown_locked()
|
|
156
|
+
|
|
157
|
+
def _connect_locked(self) -> dict[str, Tool]:
|
|
158
|
+
if not mcp_available():
|
|
159
|
+
names = ", ".join(self._servers)
|
|
160
|
+
raise MCPError(
|
|
161
|
+
f"Workflow declares MCP servers ({names}) but the mcp extra is not installed. "
|
|
162
|
+
'Run: pip install -e ".[mcp]"'
|
|
163
|
+
)
|
|
164
|
+
self._loop = _AsyncLoop()
|
|
165
|
+
try:
|
|
166
|
+
return self._loop.run(self._connect())
|
|
167
|
+
except BaseException:
|
|
168
|
+
self._shutdown_locked()
|
|
169
|
+
raise
|
|
170
|
+
|
|
171
|
+
def _shutdown_locked(self) -> None:
|
|
172
|
+
loop = self._loop
|
|
173
|
+
if loop is not None:
|
|
174
|
+
try:
|
|
175
|
+
if self._stack is not None:
|
|
176
|
+
loop.run(self._stack.aclose(), timeout=15)
|
|
177
|
+
except Exception: # noqa: BLE001
|
|
178
|
+
pass
|
|
179
|
+
loop.stop()
|
|
180
|
+
self._loop = None
|
|
181
|
+
self._stack = None
|
|
182
|
+
self._sessions.clear()
|
|
183
|
+
self._tools = None
|
|
184
|
+
|
|
185
|
+
async def _connect(self) -> dict[str, Tool]:
|
|
186
|
+
from mcp import ClientSession, StdioServerParameters
|
|
187
|
+
from mcp.client.stdio import stdio_client
|
|
188
|
+
|
|
189
|
+
stack = AsyncExitStack()
|
|
190
|
+
tools: dict[str, Tool] = {}
|
|
191
|
+
try:
|
|
192
|
+
for name, spec in self._servers.items():
|
|
193
|
+
params = StdioServerParameters(
|
|
194
|
+
command=spec.command,
|
|
195
|
+
args=spec.args,
|
|
196
|
+
env=mcp_child_env(spec),
|
|
197
|
+
cwd=str(self._cwds[name]),
|
|
198
|
+
)
|
|
199
|
+
try:
|
|
200
|
+
read, write = await stack.enter_async_context(stdio_client(params))
|
|
201
|
+
session = await stack.enter_async_context(ClientSession(read, write))
|
|
202
|
+
await session.initialize()
|
|
203
|
+
listed = await session.list_tools()
|
|
204
|
+
except MCPError:
|
|
205
|
+
raise
|
|
206
|
+
except Exception as exc: # noqa: BLE001
|
|
207
|
+
raise MCPError(f"Failed to connect to MCP server '{name}': {exc}") from exc
|
|
208
|
+
self._sessions[name] = session
|
|
209
|
+
for item in listed.tools:
|
|
210
|
+
tool_name = item.name
|
|
211
|
+
qualified = f"{name}.{tool_name}"
|
|
212
|
+
desc = item.description or ""
|
|
213
|
+
tools[qualified] = FunctionTool(
|
|
214
|
+
name=qualified,
|
|
215
|
+
description=desc or f"MCP tool {tool_name} from {name}",
|
|
216
|
+
handler=self._handler(name, tool_name),
|
|
217
|
+
schema=_schema_from_mcp_tool(item),
|
|
218
|
+
)
|
|
219
|
+
except BaseException:
|
|
220
|
+
await stack.aclose()
|
|
221
|
+
raise
|
|
222
|
+
self._stack = stack
|
|
223
|
+
return tools
|
|
224
|
+
|
|
225
|
+
def _handler(self, server: str, tool_name: str) -> Any:
|
|
226
|
+
def handler(**kwargs: Any) -> Any:
|
|
227
|
+
return self._call(server, tool_name, kwargs)
|
|
228
|
+
|
|
229
|
+
return handler
|
|
230
|
+
|
|
231
|
+
def _call(self, server: str, tool_name: str, arguments: dict[str, Any]) -> Any:
|
|
232
|
+
with self._lock:
|
|
233
|
+
loop = self._loop
|
|
234
|
+
session = self._sessions.get(server)
|
|
235
|
+
if loop is None or session is None:
|
|
236
|
+
raise MCPError(f"MCP server '{server}' is not connected")
|
|
237
|
+
try:
|
|
238
|
+
return loop.run(self._call_on(session, tool_name, arguments))
|
|
239
|
+
except MCPError:
|
|
240
|
+
raise
|
|
241
|
+
except Exception as exc: # noqa: BLE001
|
|
242
|
+
raise MCPError(f"MCP tool '{tool_name}' failed: {exc}") from exc
|
|
243
|
+
|
|
244
|
+
async def _call_on(self, session: Any, tool_name: str, arguments: dict[str, Any]) -> Any:
|
|
245
|
+
try:
|
|
246
|
+
result = await session.call_tool(tool_name, arguments)
|
|
247
|
+
except MCPError:
|
|
248
|
+
raise
|
|
249
|
+
except Exception as exc: # noqa: BLE001
|
|
250
|
+
raise MCPError(f"MCP tool '{tool_name}' failed: {exc}") from exc
|
|
251
|
+
if getattr(result, "isError", False) or getattr(result, "is_error", False):
|
|
252
|
+
raise MCPError(f"MCP tool '{tool_name}' returned an error: {result}")
|
|
253
|
+
return _result_text(result)
|