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.
- framework_agents/__init__.py +345 -0
- framework_agents/a2a_client.py +360 -0
- framework_agents/a2a_server.py +495 -0
- framework_agents/agent.py +920 -0
- framework_agents/checkpoint.py +231 -0
- framework_agents/config.py +200 -0
- framework_agents/conversation.py +249 -0
- framework_agents/errors.py +47 -0
- framework_agents/evals.py +127 -0
- framework_agents/mcp_client.py +477 -0
- framework_agents/memory.py +103 -0
- framework_agents/memory_compaction.py +63 -0
- framework_agents/middleware.py +1302 -0
- framework_agents/models/__init__.py +100 -0
- framework_agents/models/anthropic.py +339 -0
- framework_agents/models/capabilities.py +45 -0
- framework_agents/models/ollama.py +47 -0
- framework_agents/models/openai_compatible.py +409 -0
- framework_agents/observability.py +84 -0
- framework_agents/ontology_review.py +321 -0
- framework_agents/orchestration.py +169 -0
- framework_agents/pricing.py +63 -0
- framework_agents/prompts.py +132 -0
- framework_agents/rag.py +123 -0
- framework_agents/remote_sparql.py +213 -0
- framework_agents/runtime.py +88 -0
- framework_agents/sandbox.py +327 -0
- framework_agents/semantic.py +1614 -0
- framework_agents/serve.py +436 -0
- framework_agents/sessions.py +249 -0
- framework_agents/skills.py +213 -0
- framework_agents/state.py +89 -0
- framework_agents/streaming.py +74 -0
- framework_agents/swarm.py +318 -0
- framework_agents/tools.py +114 -0
- framework_agents/tracing.py +131 -0
- framework_agents/types.py +262 -0
- framework_agents/workflows.py +130 -0
- framework_agents-0.1.0.dist-info/METADATA +118 -0
- framework_agents-0.1.0.dist-info/RECORD +41 -0
- 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
|