framework-agents 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 (41) hide show
  1. framework_agents/__init__.py +345 -0
  2. framework_agents/a2a_client.py +360 -0
  3. framework_agents/a2a_server.py +495 -0
  4. framework_agents/agent.py +920 -0
  5. framework_agents/checkpoint.py +231 -0
  6. framework_agents/config.py +200 -0
  7. framework_agents/conversation.py +249 -0
  8. framework_agents/errors.py +47 -0
  9. framework_agents/evals.py +127 -0
  10. framework_agents/mcp_client.py +477 -0
  11. framework_agents/memory.py +103 -0
  12. framework_agents/memory_compaction.py +63 -0
  13. framework_agents/middleware.py +1302 -0
  14. framework_agents/models/__init__.py +100 -0
  15. framework_agents/models/anthropic.py +339 -0
  16. framework_agents/models/capabilities.py +45 -0
  17. framework_agents/models/ollama.py +47 -0
  18. framework_agents/models/openai_compatible.py +409 -0
  19. framework_agents/observability.py +84 -0
  20. framework_agents/ontology_review.py +321 -0
  21. framework_agents/orchestration.py +169 -0
  22. framework_agents/pricing.py +63 -0
  23. framework_agents/prompts.py +132 -0
  24. framework_agents/rag.py +123 -0
  25. framework_agents/remote_sparql.py +213 -0
  26. framework_agents/runtime.py +88 -0
  27. framework_agents/sandbox.py +327 -0
  28. framework_agents/semantic.py +1614 -0
  29. framework_agents/serve.py +436 -0
  30. framework_agents/sessions.py +249 -0
  31. framework_agents/skills.py +213 -0
  32. framework_agents/state.py +89 -0
  33. framework_agents/streaming.py +74 -0
  34. framework_agents/swarm.py +318 -0
  35. framework_agents/tools.py +114 -0
  36. framework_agents/tracing.py +131 -0
  37. framework_agents/types.py +262 -0
  38. framework_agents/workflows.py +130 -0
  39. framework_agents-0.1.0.dist-info/METADATA +118 -0
  40. framework_agents-0.1.0.dist-info/RECORD +41 -0
  41. framework_agents-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,47 @@
1
+ """Parity: src/types/index.ts AgentError — code + retryable with stamping."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ _MARKER = "framework_retryable"
8
+
9
+
10
+ class AgentError(Exception):
11
+ """Framework wide error: every raise carries a code and a retryable verdict.
12
+
13
+ `retryable` is the portable retry contract (TS stampRetryable equivalent):
14
+ network/429/5xx -> True, budget/truncation enforcement -> False.
15
+ """
16
+
17
+ def __init__(self, code: str, message: str, retryable: bool = False) -> None:
18
+ super().__init__(message)
19
+ self.code = code
20
+ self.retryable = retryable
21
+
22
+ def __repr__(self) -> str: # pragma: no cover - debugging helper
23
+ return f"AgentError({self.code!r}, retryable={self.retryable})"
24
+
25
+
26
+ def stamp_retryable(err: BaseException, retryable: bool) -> BaseException:
27
+ """Override a non-AgentError's retry verdict (TS stampRetryable)."""
28
+ setattr(err, _MARKER, retryable)
29
+ return err
30
+
31
+
32
+ def get_retryable(err: BaseException) -> bool | None:
33
+ """Read the retry verdict: explicit stamp > AgentError field > None (unknown)."""
34
+ stamped = getattr(err, _MARKER, None)
35
+ if stamped is not None:
36
+ return bool(stamped)
37
+ if isinstance(err, AgentError):
38
+ return err.retryable
39
+ return None
40
+
41
+
42
+ def _why(exc: BaseException) -> str:
43
+ return str(exc) or exc.__class__.__name__
44
+
45
+
46
+ def summarize(exc: BaseException) -> dict[str, Any]:
47
+ return {"code": exc.code if isinstance(exc, AgentError) else "ERROR", "message": _why(exc)}
@@ -0,0 +1,127 @@
1
+ """Evals: JSONL datasets + scoring + pass-rate reporter.
2
+
3
+ Parity: packages/typescript/src/evals/index.ts. Offline by default (scripted
4
+ models). `no_structured_error` is declared for shape parity — a structured
5
+ failure surfaces as a thrown error (scored "threw") exactly like TS.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import Any, Callable
14
+
15
+ from .agent import Agent
16
+ from .types import TokenUsage
17
+
18
+
19
+ @dataclass
20
+ class EvalExpect:
21
+ contains: list[str] = field(default_factory=list)
22
+ tool_used: list[str] = field(default_factory=list)
23
+ no_structured_error: bool | None = None
24
+
25
+
26
+ @dataclass
27
+ class EvalCase:
28
+ id: str
29
+ input: str
30
+ expect: EvalExpect | None = None
31
+
32
+
33
+ # TS names the type EvalCase; the lowercase spelling is a documented alias.
34
+ eval_case = EvalCase
35
+
36
+
37
+ @dataclass
38
+ class EvalScore:
39
+ id: str
40
+ passed: bool
41
+ detail: str
42
+ usage: TokenUsage
43
+
44
+
45
+ @dataclass
46
+ class EvalRun:
47
+ scores: list[EvalScore]
48
+ pass_rate: float
49
+
50
+
51
+ def _expect_from(raw: Any) -> EvalExpect:
52
+ if isinstance(raw, EvalExpect):
53
+ return raw
54
+ if isinstance(raw, dict):
55
+ return EvalExpect(**raw)
56
+ if raw is None:
57
+ return EvalExpect()
58
+ raise ValueError(f"Invalid eval expect: {raw!r}")
59
+
60
+
61
+ def load_jsonl(path: str | Path) -> list[EvalCase]:
62
+ """Read one JSON EvalCase per non-blank line. A UTF-8 BOM (PowerShell
63
+ writes JSONL with one) is stripped before parsing — without it line 1
64
+ would fail json.loads."""
65
+ cases: list[EvalCase] = []
66
+ # utf-8-sig transparently drops a leading BOM when present.
67
+ for line in Path(path).read_text(encoding="utf-8-sig").split("\n"):
68
+ line = line.strip().strip("")
69
+ if not line:
70
+ continue
71
+ raw = json.loads(line)
72
+ cases.append(
73
+ EvalCase(
74
+ id=raw["id"],
75
+ input=raw["input"],
76
+ expect=_expect_from(raw.get("expect")),
77
+ )
78
+ )
79
+ return cases
80
+
81
+
82
+ async def run_evals(
83
+ cases: list[EvalCase],
84
+ make_agent: Callable[[EvalCase], Agent],
85
+ ) -> EvalRun:
86
+ """Run every case sequentially, scoring text contains + tool usage."""
87
+ scores: list[EvalScore] = []
88
+ for case in cases:
89
+ try:
90
+ res = await make_agent(case).run(case.input)
91
+ expect = case.expect or EvalExpect()
92
+ missing = [s for s in expect.contains if s not in (res.text or "")]
93
+ missing_tools = [
94
+ t for t in expect.tool_used if not any(tc.name == t for tc in res.tool_calls)
95
+ ]
96
+ passed = not missing and not missing_tools
97
+ detail = (
98
+ "ok"
99
+ if passed
100
+ else f"missing text {json.dumps(missing)} tools {json.dumps(missing_tools)} :: {res.text[:120]}"
101
+ )
102
+ scores.append(EvalScore(id=case.id, passed=passed, detail=detail, usage=res.usage))
103
+ except Exception as err: # noqa: BLE001 - a throwing case scores FAIL, not crash
104
+ scores.append(
105
+ EvalScore(id=case.id, passed=False, detail=f"threw: {err}", usage=TokenUsage())
106
+ )
107
+ pass_rate = (sum(1 for s in scores if s.passed) / len(scores)) if scores else 1.0
108
+ return EvalRun(scores=scores, pass_rate=pass_rate)
109
+
110
+
111
+ def print_report(scores: list[EvalScore], pass_rate: float) -> None:
112
+ for s in scores:
113
+ print(f"{'PASS' if s.passed else 'FAIL'} {s.id} {s.detail}")
114
+ passed = sum(1 for s in scores if s.passed)
115
+ print(f"\npass rate: {pass_rate * 100:.1f}% ({passed}/{len(scores)})")
116
+
117
+
118
+ __all__ = [
119
+ "EvalCase",
120
+ "EvalExpect",
121
+ "EvalRun",
122
+ "EvalScore",
123
+ "eval_case",
124
+ "load_jsonl",
125
+ "print_report",
126
+ "run_evals",
127
+ ]
@@ -0,0 +1,477 @@
1
+ """MCP client: stdio + Streamable HTTP transports, and tool bridging.
2
+
3
+ Parity: src/mcp/index.ts.
4
+
5
+ Pinned behaviors (the TS fixes are baked in):
6
+
7
+ - initialize handshake sends clientInfo {"name": "framework-agents",
8
+ "version": "0.1.0"}; `notifications/initialized` is a NOTIFICATION: sent
9
+ without an id and never awaited on a response (stdio: fire-and-forget;
10
+ HTTP: the reply body is read and DISCARDED — streamable servers answer
11
+ notifications with 202/SSE and a client waiting on them would hang).
12
+ - stdio: line-delimited JSON-RPC with id correlation. The spawned process
13
+ EXITING (crash, OOM, kill) rejects every pending request — without this
14
+ 'error' alone would never fire and in-flight calls would hang forever.
15
+ - close() settles in-flight requests with an error instead of dropping them.
16
+ - tools/call unpacks content text parts; isError=True raises MCP_TOOL_ERROR.
17
+ - mcp_tools_to_framework bridges JSON Schema -> pydantic dynamically. Enums
18
+ become Literal (string) or Union[Literal, ...] (numbers/mixed). A tool
19
+ whose schema cannot be bridged degrades to a permissive schema — one bad
20
+ tool never crashes bridging for ALL of them.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import itertools
27
+ import json
28
+ from typing import Any, Literal, Optional, Protocol, Type, Union
29
+
30
+ import httpx
31
+ from pydantic import BaseModel, ConfigDict, create_model
32
+
33
+ from .errors import AgentError
34
+ from .tools import Tool, define_tool
35
+
36
+ CLIENT_NAME = "framework-agents"
37
+ CLIENT_VERSION = "0.1.0"
38
+ PROTOCOL_VERSION = "2024-11-05"
39
+
40
+ _rpc_seq = itertools.count(1)
41
+
42
+
43
+ class McpToolDef(dict):
44
+ """Loose dict shape: {name, description?, inputSchema}."""
45
+
46
+
47
+ def _check_rpc(res: dict[str, Any], what: str) -> Any:
48
+ error = res.get("error")
49
+ if error:
50
+ raise AgentError("MCP_ERROR", f"MCP {what}: {error.get('message', '')} ({error.get('code')})", retryable=False)
51
+ return res.get("result")
52
+
53
+
54
+ def _text_of_content(result: dict[str, Any]) -> str:
55
+ content = result.get("content") if isinstance(result, dict) else None
56
+ parts = [c.get("text", "") or "" for c in content if isinstance(c, dict) and c.get("type") == "text"] if isinstance(content, list) else []
57
+ return "\n".join(parts)
58
+
59
+
60
+ def _call_result_check(name: str, result: Any) -> str:
61
+ if not isinstance(result, dict):
62
+ return ""
63
+ text = _text_of_content(result)
64
+ if result.get("isError"):
65
+ raise AgentError("MCP_TOOL_ERROR", f'MCP tool "{name}" failed: {text}', retryable=False)
66
+ return text
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # stdio transport
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ class McpStdioClient:
75
+ """Spawns `command args`, speaks line-delimited JSON-RPC on its stdio."""
76
+
77
+ def __init__(
78
+ self,
79
+ command: str,
80
+ args: Optional[list[str]] = None,
81
+ *,
82
+ client_name: str = CLIENT_NAME,
83
+ server_id: Optional[str] = None,
84
+ ) -> None:
85
+ self.command = command
86
+ self.args = list(args or [])
87
+ self.server_id = server_id or " ".join([command, *self.args]).strip()
88
+ self.client_name = client_name
89
+ self.proc: Optional[asyncio.subprocess.Process] = None
90
+ self._pending: dict[int, "asyncio.Future[dict[str, Any]]"] = {}
91
+ self._reader_task: Optional["asyncio.Task[None]"] = None
92
+ self._watcher_task: Optional["asyncio.Task[None]"] = None
93
+ self._client_info = {"name": self.client_name, "version": CLIENT_VERSION}
94
+
95
+ async def connect(self) -> None:
96
+ try:
97
+ self.proc = await asyncio.create_subprocess_exec(
98
+ self.command,
99
+ *self.args,
100
+ stdin=asyncio.subprocess.PIPE,
101
+ stdout=asyncio.subprocess.PIPE,
102
+ stderr=asyncio.subprocess.DEVNULL,
103
+ )
104
+ except (OSError, ValueError) as err:
105
+ raise AgentError("MCP_TRANSPORT", f"Cannot spawn MCP server: {err}", retryable=False) from err
106
+ assert self.proc.stdout is not None and self.proc.stdin is not None
107
+ self._reader_task = asyncio.ensure_future(self._read_loop())
108
+ self._watcher_task = asyncio.ensure_future(self._watch_exit())
109
+ await self._request(
110
+ "initialize",
111
+ {"protocolVersion": PROTOCOL_VERSION, "capabilities": {}, "clientInfo": self._client_info},
112
+ )
113
+ # Notification: no id, no response wait (any reply line has no id to match).
114
+ self._send_line({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}})
115
+ try:
116
+ await self._flush() # best effort — a dead pipe must not fail connect()
117
+ except AgentError: # pragma: no cover - server died right after init
118
+ pass
119
+
120
+ def _send_line(self, message: dict[str, Any]) -> None:
121
+ if self.proc is None or self.proc.stdin is None:
122
+ raise AgentError("MCP_NOT_CONNECTED", "Call connect() first", retryable=False)
123
+ self.proc.stdin.write(json.dumps(message).encode("utf-8") + b"\n")
124
+
125
+ async def _flush(self) -> None:
126
+ if self.proc is not None and self.proc.stdin is not None:
127
+ try:
128
+ await self.proc.stdin.drain()
129
+ except (BrokenPipeError, ConnectionResetError) as err:
130
+ raise AgentError("MCP_TRANSPORT", f"MCP stdio write failed: {err}", retryable=True) from err
131
+
132
+ def _reject_pending(self, message: str) -> None:
133
+ """Settle in-flight requests — dropping them (TS once did) hangs callers."""
134
+ for fut in list(self._pending.values()):
135
+ if not fut.done():
136
+ fut.set_exception(AgentError("MCP_TRANSPORT", message, retryable=True))
137
+ self._pending.clear()
138
+
139
+ async def _read_loop(self) -> None:
140
+ loop = asyncio.get_running_loop()
141
+ while True:
142
+ assert self.proc is not None and self.proc.stdout is not None
143
+ try:
144
+ line = await self.proc.stdout.readline()
145
+ except asyncio.CancelledError:
146
+ return
147
+ if not line:
148
+ break # EOF
149
+ stripped = line.strip()
150
+ if not stripped:
151
+ continue
152
+ try:
153
+ msg = json.loads(stripped)
154
+ except ValueError:
155
+ continue
156
+ mid = msg.get("id") if isinstance(msg, dict) else None
157
+ fut = self._pending.get(mid)
158
+ if fut is not None and not fut.done():
159
+ loop.call_soon(fut.set_result, msg)
160
+ # EOF (stdout closed, process dead or wedged): in-flight requests
161
+ # would otherwise hang forever.
162
+ self._reject_pending("MCP server closed its output stream")
163
+
164
+ async def _watch_exit(self) -> None:
165
+ # Spawn succeeded but the process later dies (crash, OOM, kill):
166
+ # without this watcher in-flight requests hang forever.
167
+ assert self.proc is not None
168
+ code = await self.proc.wait()
169
+ self._reject_pending(f"MCP server exited (code {code})")
170
+
171
+ async def _request(self, method: str, params: Any) -> Any:
172
+ if self.proc is None or self.proc.stdin is None:
173
+ raise AgentError("MCP_NOT_CONNECTED", "Call connect() first", retryable=False)
174
+ rid = next(_rpc_seq)
175
+ fut: asyncio.Future[dict[str, Any]] = asyncio.get_running_loop().create_future()
176
+ self._pending[rid] = fut
177
+ try:
178
+ self._send_line({"jsonrpc": "2.0", "id": rid, "method": method, "params": params})
179
+ await self._flush()
180
+ reply = await fut
181
+ finally:
182
+ self._pending.pop(rid, None)
183
+ return _check_rpc(reply, method)
184
+
185
+ async def list_tools(self) -> list[dict[str, Any]]:
186
+ result = await self._request("tools/list", {})
187
+ return list(result.get("tools") or []) if isinstance(result, dict) else []
188
+
189
+ async def call_tool(self, name: str, args: Any) -> str:
190
+ result = await self._request("tools/call", {"name": name, "arguments": args if args is not None else {}})
191
+ return _call_result_check(name, result)
192
+
193
+ async def close(self) -> None:
194
+ proc = self.proc
195
+ self.proc = None
196
+ if proc is not None and proc.returncode is None:
197
+ try:
198
+ proc.kill()
199
+ except ProcessLookupError: # pragma: no cover - already dead
200
+ pass
201
+ # Settle in-flight requests BEFORE the exit watcher races us.
202
+ self._reject_pending("MCP client closed")
203
+ for task in (self._reader_task, self._watcher_task):
204
+ if task is not None:
205
+ task.cancel()
206
+ try:
207
+ await task
208
+ except (asyncio.CancelledError, Exception): # noqa: BLE001
209
+ pass
210
+ self._reader_task = self._watcher_task = None
211
+ if proc is not None:
212
+ try:
213
+ await proc.wait()
214
+ except Exception: # noqa: BLE001 - process may be awaited already
215
+ pass
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Streamable HTTP transport
220
+ # ---------------------------------------------------------------------------
221
+
222
+
223
+ async def _parse_streamable(res: httpx.Response) -> dict[str, Any]:
224
+ """JSON or (streamable) SSE body -> the last response-shaped message."""
225
+ content_type = res.headers.get("content-type", "")
226
+ if "text/event-stream" in content_type:
227
+ lines = res.text.split("\n")
228
+ last: Optional[dict[str, Any]] = None
229
+ for line in lines:
230
+ stripped = line.strip()
231
+ if not stripped.startswith("data:"):
232
+ continue
233
+ payload = stripped[5:].strip()
234
+ if payload == "[DONE]":
235
+ continue
236
+ try:
237
+ msg = json.loads(payload)
238
+ except ValueError:
239
+ continue
240
+ if isinstance(msg, dict) and (msg.get("id") is not None or msg.get("result") is not None or msg.get("error")):
241
+ last = msg
242
+ if last is None:
243
+ raise AgentError("MCP_PROTOCOL", "Empty SSE response from MCP server", retryable=True)
244
+ return last
245
+ body = res.json()
246
+ return body if isinstance(body, dict) else {}
247
+
248
+
249
+ class McpHttpClient:
250
+ """MCP client over Streamable HTTP (POST JSON-RPC; JSON or SSE responses)."""
251
+
252
+ def __init__(
253
+ self,
254
+ url: str,
255
+ headers: Optional[dict[str, str]] = None,
256
+ *,
257
+ timeout_ms: Optional[float] = None,
258
+ http_client: Optional[httpx.AsyncClient] = None,
259
+ client_name: str = CLIENT_NAME,
260
+ server_id: Optional[str] = None,
261
+ ) -> None:
262
+ self.url = url.rstrip("/")
263
+ self.server_id = server_id or url
264
+ self._extra_headers = dict(headers or {})
265
+ self._owns_client = http_client is None
266
+ # httpx's own default timeout unless the caller pins one (timeout_ms).
267
+ timeout: Any = httpx.Timeout(timeout_ms / 1000) if timeout_ms is not None else None
268
+ self._client = http_client or httpx.AsyncClient(timeout=timeout)
269
+ self._session_id: Optional[str] = None
270
+ self._connected = False
271
+ self.client_name = client_name
272
+
273
+ async def _rpc_inner(self, method: str, params: Any, with_id: bool) -> Any:
274
+ body: dict[str, Any] = {
275
+ "jsonrpc": "2.0",
276
+ "method": method,
277
+ "params": params if params is not None else {},
278
+ }
279
+ if with_id:
280
+ body["id"] = next(_rpc_seq)
281
+ headers = {
282
+ "content-type": "application/json",
283
+ "accept": "application/json, text/event-stream",
284
+ **({"mcp-session-id": self._session_id} if self._session_id else {}),
285
+ **self._extra_headers,
286
+ }
287
+ try:
288
+ res = await self._client.post(self.url, headers=headers, json=body)
289
+ except httpx.HTTPError as err:
290
+ raise AgentError("MCP_TRANSPORT", f"MCP request failed: {err}", retryable=True) from err
291
+ session = res.headers.get("mcp-session-id")
292
+ if session:
293
+ self._session_id = session
294
+ if res.status_code >= 400:
295
+ raise AgentError("MCP_TRANSPORT", f"MCP HTTP {res.status_code}", retryable=res.status_code >= 500)
296
+ if not with_id:
297
+ # Notifications carry no id: no response will ever match. Read and
298
+ # DISCARD whatever the server sends instead of hanging on it.
299
+ try:
300
+ await res.aread()
301
+ except httpx.HTTPError:
302
+ pass
303
+ return None
304
+ return _check_rpc(await _parse_streamable(res), method)
305
+
306
+ async def _rpc(self, method: str, params: Any = None) -> Any:
307
+ return await self._rpc_inner(method, params, True)
308
+
309
+ async def _notify(self, method: str, params: Any = None) -> None:
310
+ await self._rpc_inner(method, params, False)
311
+
312
+ async def connect(self) -> dict[str, Any]:
313
+ result = await self._rpc(
314
+ "initialize",
315
+ {"protocolVersion": PROTOCOL_VERSION, "capabilities": {}, "clientInfo": {"name": self.client_name, "version": CLIENT_VERSION}},
316
+ )
317
+ try:
318
+ await self._notify("notifications/initialized", {})
319
+ except AgentError:
320
+ pass # a notification failure must not break a live session
321
+ self._connected = True
322
+ server_info = result.get("serverInfo") if isinstance(result, dict) else {}
323
+ server_info = server_info if isinstance(server_info, dict) else {}
324
+ return {"name": server_info.get("name") or self.server_id, "version": server_info.get("version")}
325
+
326
+ def _require_connected(self) -> None:
327
+ if not self._connected:
328
+ raise AgentError("MCP_NOT_CONNECTED", "Call connect() first", retryable=False)
329
+
330
+ async def list_tools(self) -> list[dict[str, Any]]:
331
+ self._require_connected()
332
+ result = await self._rpc("tools/list", {})
333
+ return list(result.get("tools") or []) if isinstance(result, dict) else []
334
+
335
+ async def call_tool(self, name: str, args: Any) -> str:
336
+ self._require_connected()
337
+ result = await self._rpc("tools/call", {"name": name, "arguments": args if args is not None else {}})
338
+ return _call_result_check(name, result)
339
+
340
+ async def close(self) -> None:
341
+ self._connected = False
342
+ self._session_id = None
343
+ if self._owns_client:
344
+ await self._client.aclose()
345
+
346
+
347
+ # ---------------------------------------------------------------------------
348
+ # JSON Schema -> pydantic bridging
349
+ # ---------------------------------------------------------------------------
350
+
351
+
352
+ class PermissiveArgs(BaseModel):
353
+ """Degrade schema for tools whose input schema cannot be bridged."""
354
+
355
+ model_config = ConfigDict(extra="allow")
356
+
357
+
358
+ def _literal_type(value: Any) -> type:
359
+ if isinstance(value, (int, str, bytes, bool)) or value is None:
360
+ return Literal[value] # type: ignore[index]
361
+ raise TypeError(f"JSON Schema enum value not representable as a literal: {value!r}")
362
+
363
+
364
+ def _annotation_from_schema(schema: Any) -> Any:
365
+ if not isinstance(schema, dict):
366
+ return Any
367
+ if isinstance(schema.get("enum"), list):
368
+ values = schema["enum"]
369
+ if not values:
370
+ return Any
371
+ literals = [_literal_type(v) for v in values]
372
+ if len(literals) == 1:
373
+ return literals[0]
374
+ return Union[tuple(literals)] # numeric/mixed enums: Union[Literal, ...]
375
+ type_ = schema.get("type")
376
+ if type_ == "string":
377
+ return str
378
+ if type_ == "number":
379
+ return float
380
+ if type_ == "integer":
381
+ return int
382
+ if type_ == "boolean":
383
+ return bool
384
+ if type_ == "null":
385
+ return type(None)
386
+ if type_ == "array":
387
+ items = schema.get("items")
388
+ return list[_annotation_from_schema(items) if isinstance(items, dict) else Any] # type: ignore[misc]
389
+ if type_ == "object":
390
+ return json_schema_to_model(schema, name="Nested")
391
+ return Any
392
+
393
+
394
+ def json_schema_to_model(schema: dict[str, Any], name: str = "McpArgs") -> Type[BaseModel]:
395
+ """Minimal JSON Schema -> pydantic (objects, scalars, arrays; else Any)."""
396
+ if not isinstance(schema, dict) or schema.get("type") not in ("object", None):
397
+ raise TypeError(f"Unsupported tool input schema shape: {schema.get('type')!r}")
398
+ props = schema.get("properties") or {}
399
+ if not isinstance(props, dict):
400
+ props = {}
401
+ required = set(schema.get("required") or [])
402
+ fields: dict[str, Any] = {}
403
+ for key, sub in props.items():
404
+ annotation = _annotation_from_schema(sub if isinstance(sub, dict) else {})
405
+ if key in required:
406
+ fields[key] = (annotation, ...)
407
+ else:
408
+ fields[key] = (Optional[annotation], None) # type: ignore[misc]
409
+ # extra="allow" mirrors z.passthrough — unknown props keep flowing.
410
+ model = create_model(name, __config__=ConfigDict(extra="allow"), **fields)
411
+ return model
412
+
413
+
414
+ class McpClient(Protocol):
415
+ server_id: str
416
+
417
+ async def list_tools(self) -> list[dict[str, Any]]: ...
418
+
419
+ async def call_tool(self, name: str, args: Any) -> str: ...
420
+
421
+ async def close(self) -> None: ...
422
+
423
+
424
+ async def mcp_tools_to_framework(
425
+ client: McpClient,
426
+ defs: Optional[list[dict[str, Any]]] = None,
427
+ ) -> list[Tool]:
428
+ """Turn MCP tool defs into framework Tools (`server.tool` on name collision).
429
+
430
+ One unbridgeable schema degrades to PermissiveArgs — never to a crash that
431
+ would lose ALL of the server's tools.
432
+ """
433
+ tools = defs if defs is not None else await client.list_tools()
434
+ seen: set[str] = set()
435
+ out: list[Tool] = []
436
+ for d in tools:
437
+ base = str(d.get("name") or "")
438
+ name = base if base not in seen else f"{client.server_id}.{base}"
439
+ seen.add(base)
440
+ try:
441
+ schema: Type[BaseModel] = json_schema_to_model(d.get("inputSchema") or {"type": "object"}, name=f"Mcp{base}Args")
442
+ except Exception: # noqa: BLE001 — one bad schema, permissive tool
443
+ schema = PermissiveArgs
444
+ description = d.get("description") or f"MCP tool {base} (via {client.server_id})"
445
+ out.append(define_tool(name=name, description=description, schema=schema, execute=_tool_execute(client, base)))
446
+ return out
447
+
448
+
449
+ def _tool_execute(client: McpClient, tool_name: str) -> Any:
450
+ async def execute(args: Any, ctx: Any) -> str:
451
+ payload: Any
452
+ if isinstance(args, BaseModel):
453
+ # TS zod omits absent keys; pydantic's plain model_dump() fills
454
+ # UNSET optional fields with explicit nulls. Serialize only what
455
+ # the model actually set (model_fields_set) and never a null: an
456
+ # optional arg omitted by the caller is ABSENT from the wire
457
+ # payload, and present-but-null never appears either.
458
+ stored = set(getattr(args, "model_fields_set", None) or ())
459
+ dumped = args.model_dump(exclude_none=True)
460
+ payload = {k: v for k, v in dumped.items() if k in stored} if stored else dumped
461
+ else:
462
+ payload = args
463
+ return await client.call_tool(tool_name, payload)
464
+
465
+ return execute
466
+
467
+
468
+ async def connect_mcp_http(url: str, headers: Optional[dict[str, str]] = None, **opts: Any) -> McpHttpClient:
469
+ client = McpHttpClient(url, headers, **opts)
470
+ await client.connect()
471
+ return client
472
+
473
+
474
+ async def connect_mcp_stdio(command: str, args: Optional[list[str]] = None, **opts: Any) -> McpStdioClient:
475
+ client = McpStdioClient(command, args, **opts)
476
+ await client.connect()
477
+ return client