DeepFabric 4.4.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.
- deepfabric/__init__.py +70 -0
- deepfabric/__main__.py +6 -0
- deepfabric/auth.py +382 -0
- deepfabric/builders.py +303 -0
- deepfabric/builders_agent.py +1304 -0
- deepfabric/cli.py +1288 -0
- deepfabric/config.py +899 -0
- deepfabric/config_manager.py +251 -0
- deepfabric/constants.py +94 -0
- deepfabric/dataset_manager.py +534 -0
- deepfabric/error_codes.py +581 -0
- deepfabric/evaluation/__init__.py +47 -0
- deepfabric/evaluation/backends/__init__.py +32 -0
- deepfabric/evaluation/backends/ollama_backend.py +137 -0
- deepfabric/evaluation/backends/tool_call_parsers.py +409 -0
- deepfabric/evaluation/backends/transformers_backend.py +326 -0
- deepfabric/evaluation/evaluator.py +845 -0
- deepfabric/evaluation/evaluators/__init__.py +13 -0
- deepfabric/evaluation/evaluators/base.py +104 -0
- deepfabric/evaluation/evaluators/builtin/__init__.py +5 -0
- deepfabric/evaluation/evaluators/builtin/tool_calling.py +93 -0
- deepfabric/evaluation/evaluators/registry.py +66 -0
- deepfabric/evaluation/inference.py +155 -0
- deepfabric/evaluation/metrics.py +397 -0
- deepfabric/evaluation/parser.py +304 -0
- deepfabric/evaluation/reporters/__init__.py +13 -0
- deepfabric/evaluation/reporters/base.py +56 -0
- deepfabric/evaluation/reporters/cloud_reporter.py +195 -0
- deepfabric/evaluation/reporters/file_reporter.py +61 -0
- deepfabric/evaluation/reporters/multi_reporter.py +56 -0
- deepfabric/exceptions.py +67 -0
- deepfabric/factory.py +26 -0
- deepfabric/generator.py +1084 -0
- deepfabric/graph.py +545 -0
- deepfabric/hf_hub.py +214 -0
- deepfabric/kaggle_hub.py +219 -0
- deepfabric/llm/__init__.py +41 -0
- deepfabric/llm/api_key_verifier.py +534 -0
- deepfabric/llm/client.py +1206 -0
- deepfabric/llm/errors.py +105 -0
- deepfabric/llm/rate_limit_config.py +262 -0
- deepfabric/llm/rate_limit_detector.py +278 -0
- deepfabric/llm/retry_handler.py +270 -0
- deepfabric/metrics.py +212 -0
- deepfabric/progress.py +262 -0
- deepfabric/prompts.py +290 -0
- deepfabric/schemas.py +1000 -0
- deepfabric/spin/__init__.py +6 -0
- deepfabric/spin/client.py +263 -0
- deepfabric/spin/models.py +26 -0
- deepfabric/stream_simulator.py +90 -0
- deepfabric/tools/__init__.py +5 -0
- deepfabric/tools/defaults.py +85 -0
- deepfabric/tools/loader.py +87 -0
- deepfabric/tools/mcp_client.py +677 -0
- deepfabric/topic_manager.py +303 -0
- deepfabric/topic_model.py +20 -0
- deepfabric/training/__init__.py +35 -0
- deepfabric/training/api_key_prompt.py +302 -0
- deepfabric/training/callback.py +363 -0
- deepfabric/training/metrics_sender.py +301 -0
- deepfabric/tree.py +438 -0
- deepfabric/tui.py +1267 -0
- deepfabric/update_checker.py +166 -0
- deepfabric/utils.py +150 -0
- deepfabric/validation.py +143 -0
- deepfabric-4.4.0.dist-info/METADATA +702 -0
- deepfabric-4.4.0.dist-info/RECORD +71 -0
- deepfabric-4.4.0.dist-info/WHEEL +4 -0
- deepfabric-4.4.0.dist-info/entry_points.txt +2 -0
- deepfabric-4.4.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,677 @@
|
|
|
1
|
+
"""MCP (Model Context Protocol) client for fetching tools from MCP servers.
|
|
2
|
+
|
|
3
|
+
Supports both stdio and Streamable HTTP transports as per MCP spec 2025-11-25.
|
|
4
|
+
See: https://modelcontextprotocol.io/specification/2025-11-25/basic/transports
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import contextlib
|
|
8
|
+
import json
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import selectors
|
|
12
|
+
import shlex
|
|
13
|
+
import subprocess # nosec
|
|
14
|
+
|
|
15
|
+
from typing import Any, Literal
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
import yaml
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, Field
|
|
21
|
+
|
|
22
|
+
from ..exceptions import ConfigurationError
|
|
23
|
+
from ..schemas import MCPToolDefinition, ToolDefinition, ToolRegistry
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
# MCP Protocol Version
|
|
28
|
+
MCP_PROTOCOL_VERSION = "2025-11-25"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class MCPClientInfo(BaseModel):
|
|
32
|
+
"""Client information for MCP initialization."""
|
|
33
|
+
|
|
34
|
+
name: str = Field(default="deepfabric")
|
|
35
|
+
version: str = Field(default="1.0.0")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class MCPServerInfo(BaseModel):
|
|
39
|
+
"""Server information from MCP initialization response."""
|
|
40
|
+
|
|
41
|
+
name: str
|
|
42
|
+
version: str = ""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class MCPInitializeResult(BaseModel):
|
|
46
|
+
"""Result from MCP initialize request."""
|
|
47
|
+
|
|
48
|
+
model_config = {"populate_by_name": True}
|
|
49
|
+
|
|
50
|
+
protocol_version: str = Field(alias="protocolVersion")
|
|
51
|
+
capabilities: dict[str, Any] = Field(default_factory=dict)
|
|
52
|
+
server_info: MCPServerInfo | None = Field(default=None, alias="serverInfo")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class MCPToolsListResult(BaseModel):
|
|
56
|
+
"""Result from MCP tools/list request."""
|
|
57
|
+
|
|
58
|
+
tools: list[dict[str, Any]] = Field(default_factory=list)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _create_jsonrpc_request(
|
|
62
|
+
method: str, params: dict[str, Any] | None = None, request_id: int = 1
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Create a JSON-RPC 2.0 request message."""
|
|
65
|
+
request: dict[str, Any] = {
|
|
66
|
+
"jsonrpc": "2.0",
|
|
67
|
+
"id": request_id,
|
|
68
|
+
"method": method,
|
|
69
|
+
}
|
|
70
|
+
if params is not None:
|
|
71
|
+
request["params"] = params
|
|
72
|
+
return json.dumps(request, separators=(",", ":"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _create_jsonrpc_notification(method: str, params: dict[str, Any] | None = None) -> str:
|
|
76
|
+
"""Create a JSON-RPC 2.0 notification (no id, no response expected)."""
|
|
77
|
+
notification: dict[str, Any] = {
|
|
78
|
+
"jsonrpc": "2.0",
|
|
79
|
+
"method": method,
|
|
80
|
+
}
|
|
81
|
+
if params is not None:
|
|
82
|
+
notification["params"] = params
|
|
83
|
+
return json.dumps(notification, separators=(",", ":"))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_jsonrpc_response(response: str) -> dict[str, Any]:
|
|
87
|
+
"""Parse a JSON-RPC 2.0 response."""
|
|
88
|
+
try:
|
|
89
|
+
data = json.loads(response)
|
|
90
|
+
except json.JSONDecodeError as e:
|
|
91
|
+
raise ConfigurationError(f"Invalid JSON-RPC response: {e}") from e
|
|
92
|
+
|
|
93
|
+
if "error" in data:
|
|
94
|
+
error = data["error"]
|
|
95
|
+
code = error.get("code", "unknown")
|
|
96
|
+
message = error.get("message", "Unknown error")
|
|
97
|
+
raise ConfigurationError(f"MCP server error [{code}]: {message}")
|
|
98
|
+
|
|
99
|
+
return data
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _make_timeout_error(timeout: float) -> ConfigurationError:
|
|
103
|
+
"""Create a timeout error."""
|
|
104
|
+
return ConfigurationError(f"Timeout waiting for MCP server response after {timeout}s")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _make_process_terminated_error(exit_code: int, stderr_output: str) -> ConfigurationError:
|
|
108
|
+
"""Create a process terminated error."""
|
|
109
|
+
return ConfigurationError(
|
|
110
|
+
f"MCP server process terminated unexpectedly. Exit code: {exit_code}. "
|
|
111
|
+
f"Stderr: {stderr_output}"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _make_stdout_closed_error() -> ConfigurationError:
|
|
116
|
+
"""Create a stdout closed error."""
|
|
117
|
+
return ConfigurationError("MCP server closed stdout without response")
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class StdioMCPClient:
|
|
121
|
+
"""MCP client using stdio transport.
|
|
122
|
+
|
|
123
|
+
Launches an MCP server as a subprocess and communicates via stdin/stdout.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def __init__(self, command: str, env: dict[str, str] | None = None):
|
|
127
|
+
"""Initialize stdio MCP client.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
command: Shell command to launch the MCP server
|
|
131
|
+
env: Optional environment variables to pass to the subprocess
|
|
132
|
+
"""
|
|
133
|
+
self.command = command
|
|
134
|
+
self.env = env
|
|
135
|
+
self.process: subprocess.Popen | None = None
|
|
136
|
+
self._request_id = 0
|
|
137
|
+
|
|
138
|
+
def _next_id(self) -> int:
|
|
139
|
+
"""Get the next request ID."""
|
|
140
|
+
self._request_id += 1
|
|
141
|
+
return self._request_id
|
|
142
|
+
|
|
143
|
+
def _send_message(self, message: str) -> None:
|
|
144
|
+
"""Send a message to the MCP server via stdin."""
|
|
145
|
+
if self.process is None or self.process.stdin is None:
|
|
146
|
+
raise ConfigurationError("MCP server process not started")
|
|
147
|
+
|
|
148
|
+
# Messages are newline-delimited
|
|
149
|
+
self.process.stdin.write(message + "\n")
|
|
150
|
+
self.process.stdin.flush()
|
|
151
|
+
|
|
152
|
+
def _receive_message(self, timeout: float = 30.0) -> str:
|
|
153
|
+
"""Receive a message from the MCP server via stdout."""
|
|
154
|
+
if self.process is None or self.process.stdout is None:
|
|
155
|
+
raise ConfigurationError("MCP server process not started")
|
|
156
|
+
|
|
157
|
+
return self._read_line_with_timeout(timeout)
|
|
158
|
+
|
|
159
|
+
def _read_line_with_timeout(self, timeout: float) -> str:
|
|
160
|
+
"""Read a line from stdout with timeout handling."""
|
|
161
|
+
if self.process is None or self.process.stdout is None:
|
|
162
|
+
raise ConfigurationError("MCP server process not started")
|
|
163
|
+
|
|
164
|
+
sel = selectors.DefaultSelector()
|
|
165
|
+
try:
|
|
166
|
+
sel.register(self.process.stdout, selectors.EVENT_READ)
|
|
167
|
+
events = sel.select(timeout=timeout)
|
|
168
|
+
self._check_timeout(events, timeout)
|
|
169
|
+
line = self.process.stdout.readline()
|
|
170
|
+
|
|
171
|
+
if not line:
|
|
172
|
+
self._handle_empty_line()
|
|
173
|
+
|
|
174
|
+
return line.strip()
|
|
175
|
+
except ConfigurationError:
|
|
176
|
+
raise
|
|
177
|
+
except Exception as e:
|
|
178
|
+
raise ConfigurationError(f"Error reading from MCP server: {e}") from e
|
|
179
|
+
finally:
|
|
180
|
+
sel.close()
|
|
181
|
+
|
|
182
|
+
def _check_timeout(self, events: list, timeout: float) -> None:
|
|
183
|
+
"""Check if select timed out and raise appropriate error."""
|
|
184
|
+
if not events:
|
|
185
|
+
raise _make_timeout_error(timeout)
|
|
186
|
+
|
|
187
|
+
def _handle_empty_line(self) -> None:
|
|
188
|
+
"""Handle case where stdout returns empty line."""
|
|
189
|
+
# Check if process has terminated
|
|
190
|
+
if self.process is not None and self.process.poll() is not None:
|
|
191
|
+
stderr_output = ""
|
|
192
|
+
if self.process.stderr:
|
|
193
|
+
stderr_output = self.process.stderr.read()
|
|
194
|
+
raise _make_process_terminated_error(self.process.returncode, stderr_output)
|
|
195
|
+
raise _make_stdout_closed_error()
|
|
196
|
+
|
|
197
|
+
def start(self) -> None:
|
|
198
|
+
"""Start the MCP server subprocess."""
|
|
199
|
+
# Merge environment
|
|
200
|
+
process_env = os.environ.copy()
|
|
201
|
+
if self.env:
|
|
202
|
+
process_env.update(self.env)
|
|
203
|
+
|
|
204
|
+
# Parse command - handle shell commands properly
|
|
205
|
+
try:
|
|
206
|
+
args = shlex.split(self.command)
|
|
207
|
+
except ValueError as e:
|
|
208
|
+
raise ConfigurationError(f"Invalid command: {e}") from e
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
# Security note: Command execution is intentional here - users explicitly
|
|
212
|
+
# provide MCP server commands via --command flag. We use shlex.split() to
|
|
213
|
+
# safely parse the command string and pass a list to Popen (no shell=True),
|
|
214
|
+
# which prevents shell injection. This is a local CLI tool where the user
|
|
215
|
+
# is trusted to run commands on their own machine.
|
|
216
|
+
self.process = subprocess.Popen( # nosec # noqa: S603
|
|
217
|
+
args,
|
|
218
|
+
stdin=subprocess.PIPE,
|
|
219
|
+
stdout=subprocess.PIPE,
|
|
220
|
+
stderr=subprocess.PIPE,
|
|
221
|
+
text=True,
|
|
222
|
+
env=process_env,
|
|
223
|
+
bufsize=1, # Line buffered
|
|
224
|
+
)
|
|
225
|
+
except FileNotFoundError as e:
|
|
226
|
+
raise ConfigurationError(f"MCP server command not found: {args[0]}") from e
|
|
227
|
+
except Exception as e:
|
|
228
|
+
raise ConfigurationError(f"Failed to start MCP server: {e}") from e
|
|
229
|
+
|
|
230
|
+
def stop(self) -> None:
|
|
231
|
+
"""Stop the MCP server subprocess."""
|
|
232
|
+
if self.process is not None:
|
|
233
|
+
self.process.terminate()
|
|
234
|
+
try:
|
|
235
|
+
self.process.wait(timeout=5.0)
|
|
236
|
+
except subprocess.TimeoutExpired:
|
|
237
|
+
self.process.kill()
|
|
238
|
+
self.process = None
|
|
239
|
+
|
|
240
|
+
def initialize(self) -> MCPInitializeResult:
|
|
241
|
+
"""Send initialize request to the MCP server."""
|
|
242
|
+
request = _create_jsonrpc_request(
|
|
243
|
+
"initialize",
|
|
244
|
+
{
|
|
245
|
+
"protocolVersion": MCP_PROTOCOL_VERSION,
|
|
246
|
+
"capabilities": {},
|
|
247
|
+
"clientInfo": MCPClientInfo().model_dump(),
|
|
248
|
+
},
|
|
249
|
+
request_id=self._next_id(),
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
self._send_message(request)
|
|
253
|
+
response_str = self._receive_message()
|
|
254
|
+
response = _parse_jsonrpc_response(response_str)
|
|
255
|
+
|
|
256
|
+
# Send initialized notification
|
|
257
|
+
notification = _create_jsonrpc_notification("notifications/initialized")
|
|
258
|
+
self._send_message(notification)
|
|
259
|
+
|
|
260
|
+
return MCPInitializeResult.model_validate(response.get("result", {}))
|
|
261
|
+
|
|
262
|
+
def list_tools(self) -> list[dict[str, Any]]:
|
|
263
|
+
"""Request the list of available tools from the MCP server."""
|
|
264
|
+
request = _create_jsonrpc_request("tools/list", {}, request_id=self._next_id())
|
|
265
|
+
|
|
266
|
+
self._send_message(request)
|
|
267
|
+
response_str = self._receive_message()
|
|
268
|
+
response = _parse_jsonrpc_response(response_str)
|
|
269
|
+
|
|
270
|
+
result = MCPToolsListResult.model_validate(response.get("result", {}))
|
|
271
|
+
return result.tools
|
|
272
|
+
|
|
273
|
+
def __enter__(self) -> "StdioMCPClient":
|
|
274
|
+
"""Context manager entry."""
|
|
275
|
+
self.start()
|
|
276
|
+
return self
|
|
277
|
+
|
|
278
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
279
|
+
"""Context manager exit."""
|
|
280
|
+
self.stop()
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class HTTPMCPClient:
|
|
284
|
+
"""MCP client using Streamable HTTP transport.
|
|
285
|
+
|
|
286
|
+
Communicates with an MCP server over HTTP POST/GET.
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
def __init__(
|
|
290
|
+
self,
|
|
291
|
+
endpoint: str,
|
|
292
|
+
headers: dict[str, str] | None = None,
|
|
293
|
+
timeout: float = 30.0,
|
|
294
|
+
):
|
|
295
|
+
"""Initialize HTTP MCP client.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
endpoint: HTTP endpoint URL for the MCP server
|
|
299
|
+
headers: Optional additional headers (e.g., for authentication)
|
|
300
|
+
timeout: Request timeout in seconds
|
|
301
|
+
"""
|
|
302
|
+
self.endpoint = endpoint
|
|
303
|
+
self.headers = headers or {}
|
|
304
|
+
self.timeout = timeout
|
|
305
|
+
self.session_id: str | None = None
|
|
306
|
+
self._request_id = 0
|
|
307
|
+
self._client: httpx.Client | None = None
|
|
308
|
+
|
|
309
|
+
def _next_id(self) -> int:
|
|
310
|
+
"""Get the next request ID."""
|
|
311
|
+
self._request_id += 1
|
|
312
|
+
return self._request_id
|
|
313
|
+
|
|
314
|
+
def _get_headers(self) -> dict[str, str]:
|
|
315
|
+
"""Get headers for HTTP requests."""
|
|
316
|
+
headers = {
|
|
317
|
+
"Content-Type": "application/json",
|
|
318
|
+
"Accept": "application/json, text/event-stream",
|
|
319
|
+
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
|
|
320
|
+
**self.headers,
|
|
321
|
+
}
|
|
322
|
+
if self.session_id:
|
|
323
|
+
headers["MCP-Session-Id"] = self.session_id
|
|
324
|
+
return headers
|
|
325
|
+
|
|
326
|
+
def _send_request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
327
|
+
"""Send a JSON-RPC request to the MCP server."""
|
|
328
|
+
if self._client is None:
|
|
329
|
+
raise ConfigurationError("HTTP client not started")
|
|
330
|
+
|
|
331
|
+
request_body = _create_jsonrpc_request(method, params, request_id=self._next_id())
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
response = self._client.post(
|
|
335
|
+
self.endpoint,
|
|
336
|
+
content=request_body,
|
|
337
|
+
headers=self._get_headers(),
|
|
338
|
+
timeout=self.timeout,
|
|
339
|
+
)
|
|
340
|
+
response.raise_for_status()
|
|
341
|
+
|
|
342
|
+
# Check for session ID in response headers
|
|
343
|
+
if "MCP-Session-Id" in response.headers:
|
|
344
|
+
self.session_id = response.headers["MCP-Session-Id"]
|
|
345
|
+
|
|
346
|
+
# Handle SSE response
|
|
347
|
+
content_type = response.headers.get("Content-Type", "")
|
|
348
|
+
if "text/event-stream" in content_type:
|
|
349
|
+
# Parse SSE format - look for data: lines
|
|
350
|
+
for line in response.text.split("\n"):
|
|
351
|
+
if line.startswith("data:"):
|
|
352
|
+
data_str = line[5:].strip()
|
|
353
|
+
if data_str:
|
|
354
|
+
return _parse_jsonrpc_response(data_str)
|
|
355
|
+
raise ConfigurationError("No data in SSE response")
|
|
356
|
+
|
|
357
|
+
# Regular JSON response
|
|
358
|
+
return _parse_jsonrpc_response(response.text)
|
|
359
|
+
|
|
360
|
+
except httpx.HTTPStatusError as e:
|
|
361
|
+
raise ConfigurationError(
|
|
362
|
+
f"HTTP error from MCP server: {e.response.status_code} - {e.response.text}"
|
|
363
|
+
) from e
|
|
364
|
+
except httpx.RequestError as e:
|
|
365
|
+
raise ConfigurationError(f"Failed to connect to MCP server: {e}") from e
|
|
366
|
+
|
|
367
|
+
def start(self) -> None:
|
|
368
|
+
"""Start the HTTP client."""
|
|
369
|
+
self._client = httpx.Client()
|
|
370
|
+
|
|
371
|
+
def stop(self) -> None:
|
|
372
|
+
"""Stop the HTTP client and terminate session."""
|
|
373
|
+
if self._client is not None:
|
|
374
|
+
# Send session termination if we have a session
|
|
375
|
+
if self.session_id:
|
|
376
|
+
with contextlib.suppress(Exception):
|
|
377
|
+
self._client.delete(
|
|
378
|
+
self.endpoint,
|
|
379
|
+
headers=self._get_headers(),
|
|
380
|
+
timeout=5.0,
|
|
381
|
+
)
|
|
382
|
+
self._client.close()
|
|
383
|
+
self._client = None
|
|
384
|
+
|
|
385
|
+
def initialize(self) -> MCPInitializeResult:
|
|
386
|
+
"""Send initialize request to the MCP server."""
|
|
387
|
+
response = self._send_request(
|
|
388
|
+
"initialize",
|
|
389
|
+
{
|
|
390
|
+
"protocolVersion": MCP_PROTOCOL_VERSION,
|
|
391
|
+
"capabilities": {},
|
|
392
|
+
"clientInfo": MCPClientInfo().model_dump(),
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Send initialized notification (fire and forget for HTTP)
|
|
397
|
+
with contextlib.suppress(Exception):
|
|
398
|
+
notification = _create_jsonrpc_notification("notifications/initialized")
|
|
399
|
+
if self._client:
|
|
400
|
+
self._client.post(
|
|
401
|
+
self.endpoint,
|
|
402
|
+
content=notification,
|
|
403
|
+
headers=self._get_headers(),
|
|
404
|
+
timeout=5.0,
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
return MCPInitializeResult.model_validate(response.get("result", {}))
|
|
408
|
+
|
|
409
|
+
def list_tools(self) -> list[dict[str, Any]]:
|
|
410
|
+
"""Request the list of available tools from the MCP server."""
|
|
411
|
+
response = self._send_request("tools/list", {})
|
|
412
|
+
result = MCPToolsListResult.model_validate(response.get("result", {}))
|
|
413
|
+
return result.tools
|
|
414
|
+
|
|
415
|
+
def __enter__(self) -> "HTTPMCPClient":
|
|
416
|
+
"""Context manager entry."""
|
|
417
|
+
self.start()
|
|
418
|
+
return self
|
|
419
|
+
|
|
420
|
+
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
|
|
421
|
+
"""Context manager exit."""
|
|
422
|
+
self.stop()
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _create_mcp_client(
|
|
426
|
+
transport: Literal["stdio", "http"],
|
|
427
|
+
command: str | None = None,
|
|
428
|
+
endpoint: str | None = None,
|
|
429
|
+
env: dict[str, str] | None = None,
|
|
430
|
+
headers: dict[str, str] | None = None,
|
|
431
|
+
timeout: float = 30.0,
|
|
432
|
+
) -> StdioMCPClient | HTTPMCPClient:
|
|
433
|
+
"""Create an MCP client based on transport type.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
transport: Transport type - "stdio" or "http"
|
|
437
|
+
command: Shell command to launch MCP server (required for stdio)
|
|
438
|
+
endpoint: HTTP endpoint URL (required for http)
|
|
439
|
+
env: Environment variables for stdio subprocess
|
|
440
|
+
headers: HTTP headers for authentication etc.
|
|
441
|
+
timeout: Request timeout in seconds
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Configured MCP client (StdioMCPClient or HTTPMCPClient)
|
|
445
|
+
|
|
446
|
+
Raises:
|
|
447
|
+
ConfigurationError: If required parameters are missing for the transport type
|
|
448
|
+
"""
|
|
449
|
+
if transport == "stdio":
|
|
450
|
+
if not command:
|
|
451
|
+
raise ConfigurationError("command is required for stdio transport")
|
|
452
|
+
return StdioMCPClient(command, env=env)
|
|
453
|
+
if transport == "http":
|
|
454
|
+
if not endpoint:
|
|
455
|
+
raise ConfigurationError("endpoint is required for http transport")
|
|
456
|
+
return HTTPMCPClient(endpoint, headers=headers, timeout=timeout)
|
|
457
|
+
|
|
458
|
+
raise ConfigurationError(f"Unknown transport: {transport}")
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def fetch_tools_from_mcp(
|
|
462
|
+
transport: Literal["stdio", "http"],
|
|
463
|
+
command: str | None = None,
|
|
464
|
+
endpoint: str | None = None,
|
|
465
|
+
env: dict[str, str] | None = None,
|
|
466
|
+
headers: dict[str, str] | None = None,
|
|
467
|
+
timeout: float = 30.0,
|
|
468
|
+
) -> ToolRegistry:
|
|
469
|
+
"""Fetch tools from an MCP server and convert to DeepFabric ToolRegistry.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
transport: Transport type - "stdio" or "http"
|
|
473
|
+
command: Shell command to launch MCP server (required for stdio)
|
|
474
|
+
endpoint: HTTP endpoint URL (required for http)
|
|
475
|
+
env: Environment variables for stdio subprocess
|
|
476
|
+
headers: HTTP headers for authentication etc.
|
|
477
|
+
timeout: Request timeout in seconds
|
|
478
|
+
|
|
479
|
+
Returns:
|
|
480
|
+
ToolRegistry containing the converted tools
|
|
481
|
+
|
|
482
|
+
Raises:
|
|
483
|
+
ConfigurationError: If transport params are invalid or server communication fails
|
|
484
|
+
"""
|
|
485
|
+
client = _create_mcp_client(
|
|
486
|
+
transport=transport,
|
|
487
|
+
command=command,
|
|
488
|
+
endpoint=endpoint,
|
|
489
|
+
env=env,
|
|
490
|
+
headers=headers,
|
|
491
|
+
timeout=timeout,
|
|
492
|
+
)
|
|
493
|
+
|
|
494
|
+
with client:
|
|
495
|
+
# Initialize the connection
|
|
496
|
+
init_result = client.initialize()
|
|
497
|
+
logger.info(
|
|
498
|
+
"Connected to MCP server: %s (protocol: %s)",
|
|
499
|
+
init_result.server_info.name if init_result.server_info else "unknown",
|
|
500
|
+
init_result.protocol_version,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
# Fetch tools
|
|
504
|
+
mcp_tools = client.list_tools()
|
|
505
|
+
logger.info("Fetched %d tools from MCP server", len(mcp_tools))
|
|
506
|
+
|
|
507
|
+
# Convert to ToolDefinition
|
|
508
|
+
tools = []
|
|
509
|
+
for tool_dict in mcp_tools:
|
|
510
|
+
try:
|
|
511
|
+
mcp_tool = MCPToolDefinition.model_validate(tool_dict)
|
|
512
|
+
tools.append(ToolDefinition.from_mcp(mcp_tool))
|
|
513
|
+
except Exception as e:
|
|
514
|
+
logger.warning("Failed to convert tool '%s': %s", tool_dict.get("name", "?"), e)
|
|
515
|
+
|
|
516
|
+
return ToolRegistry(tools=tools)
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def save_tools_to_file(
|
|
520
|
+
registry: ToolRegistry,
|
|
521
|
+
output_path: str,
|
|
522
|
+
output_format: Literal["deepfabric", "openai"] = "deepfabric",
|
|
523
|
+
) -> None:
|
|
524
|
+
"""Save tools to a JSON or YAML file.
|
|
525
|
+
|
|
526
|
+
Args:
|
|
527
|
+
registry: ToolRegistry to save
|
|
528
|
+
output_path: Output file path (.json or .yaml/.yml)
|
|
529
|
+
output_format: Output format - "deepfabric" (native) or "openai" (TRL compatible)
|
|
530
|
+
"""
|
|
531
|
+
if output_format == "openai":
|
|
532
|
+
data = {"tools": registry.to_openai_format()}
|
|
533
|
+
else:
|
|
534
|
+
data = {"tools": [tool.model_dump() for tool in registry.tools]}
|
|
535
|
+
|
|
536
|
+
if output_path.endswith((".yaml", ".yml")):
|
|
537
|
+
with open(output_path, "w") as f:
|
|
538
|
+
yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
|
|
539
|
+
else:
|
|
540
|
+
with open(output_path, "w") as f:
|
|
541
|
+
json.dump(data, f, indent=2)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
class SpinLoadResult(BaseModel):
|
|
545
|
+
"""Result from loading tools into Spin."""
|
|
546
|
+
|
|
547
|
+
loaded: int = Field(description="The number of tools loaded into Spin.")
|
|
548
|
+
tool_names: list[str] = Field(
|
|
549
|
+
alias="tools", description="The names of the tools loaded into Spin."
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def push_tools_to_spin(
|
|
554
|
+
mcp_tools: list[dict[str, Any]],
|
|
555
|
+
spin_endpoint: str,
|
|
556
|
+
timeout: float = 30.0,
|
|
557
|
+
) -> SpinLoadResult:
|
|
558
|
+
"""Push MCP tools directly to Spin mock component.
|
|
559
|
+
|
|
560
|
+
This posts the raw MCP tool definitions to Spin's /mock/load-schema endpoint,
|
|
561
|
+
which accepts MCP format directly.
|
|
562
|
+
|
|
563
|
+
Args:
|
|
564
|
+
mcp_tools: List of MCP tool definitions (raw dicts with inputSchema)
|
|
565
|
+
spin_endpoint: Spin server base URL (e.g., "http://localhost:3000")
|
|
566
|
+
timeout: Request timeout in seconds
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
SpinLoadResult with count and names of loaded tools
|
|
570
|
+
|
|
571
|
+
Raises:
|
|
572
|
+
ConfigurationError: If Spin server is unreachable or returns an error
|
|
573
|
+
"""
|
|
574
|
+
endpoint = spin_endpoint.rstrip("/")
|
|
575
|
+
load_url = f"{endpoint}/mock/load-schema"
|
|
576
|
+
|
|
577
|
+
# Spin accepts MCP format directly: { "tools": [...] } or just [...]
|
|
578
|
+
payload = {"tools": mcp_tools}
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
with httpx.Client(timeout=timeout) as client:
|
|
582
|
+
response = client.post(
|
|
583
|
+
load_url,
|
|
584
|
+
json=payload,
|
|
585
|
+
headers={"Content-Type": "application/json"},
|
|
586
|
+
)
|
|
587
|
+
response.raise_for_status()
|
|
588
|
+
|
|
589
|
+
data = response.json()
|
|
590
|
+
return SpinLoadResult(
|
|
591
|
+
loaded=data.get("loaded", 0),
|
|
592
|
+
tools=data.get("tools", []),
|
|
593
|
+
)
|
|
594
|
+
|
|
595
|
+
except httpx.HTTPStatusError as e:
|
|
596
|
+
error_detail = ""
|
|
597
|
+
try:
|
|
598
|
+
error_data = e.response.json()
|
|
599
|
+
error_detail = error_data.get("error", "")
|
|
600
|
+
except json.JSONDecodeError:
|
|
601
|
+
error_detail = e.response.text
|
|
602
|
+
raise ConfigurationError(
|
|
603
|
+
f"Spin server returned error {e.response.status_code}: {error_detail}"
|
|
604
|
+
) from e
|
|
605
|
+
except httpx.ConnectError as e:
|
|
606
|
+
raise ConfigurationError(
|
|
607
|
+
f"Cannot connect to Spin server at {endpoint}. Is it running?"
|
|
608
|
+
) from e
|
|
609
|
+
except httpx.RequestError as e:
|
|
610
|
+
raise ConfigurationError(f"Failed to communicate with Spin server: {e}") from e
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def fetch_and_push_to_spin(
|
|
614
|
+
transport: Literal["stdio", "http"],
|
|
615
|
+
spin_endpoint: str,
|
|
616
|
+
command: str | None = None,
|
|
617
|
+
endpoint: str | None = None,
|
|
618
|
+
env: dict[str, str] | None = None,
|
|
619
|
+
headers: dict[str, str] | None = None,
|
|
620
|
+
timeout: float = 30.0,
|
|
621
|
+
) -> tuple[ToolRegistry, SpinLoadResult]:
|
|
622
|
+
"""Fetch tools from MCP server and push to Spin in one operation.
|
|
623
|
+
|
|
624
|
+
This is more efficient than fetch + convert + push because it preserves
|
|
625
|
+
the original MCP format (with inputSchema) that Spin expects.
|
|
626
|
+
|
|
627
|
+
Args:
|
|
628
|
+
transport: MCP transport type - "stdio" or "http"
|
|
629
|
+
spin_endpoint: Spin server base URL
|
|
630
|
+
command: Shell command for stdio transport
|
|
631
|
+
endpoint: HTTP endpoint for http transport
|
|
632
|
+
env: Environment variables for stdio
|
|
633
|
+
headers: HTTP headers for http transport
|
|
634
|
+
timeout: Request timeout in seconds
|
|
635
|
+
|
|
636
|
+
Returns:
|
|
637
|
+
Tuple of (ToolRegistry for local use, SpinLoadResult from Spin)
|
|
638
|
+
|
|
639
|
+
Raises:
|
|
640
|
+
ConfigurationError: If either MCP or Spin communication fails
|
|
641
|
+
"""
|
|
642
|
+
client = _create_mcp_client(
|
|
643
|
+
transport=transport,
|
|
644
|
+
command=command,
|
|
645
|
+
endpoint=endpoint,
|
|
646
|
+
env=env,
|
|
647
|
+
headers=headers,
|
|
648
|
+
timeout=timeout,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
with client:
|
|
652
|
+
# Initialize the connection
|
|
653
|
+
init_result = client.initialize()
|
|
654
|
+
logger.info(
|
|
655
|
+
"Connected to MCP server: %s (protocol: %s)",
|
|
656
|
+
init_result.server_info.name if init_result.server_info else "unknown",
|
|
657
|
+
init_result.protocol_version,
|
|
658
|
+
)
|
|
659
|
+
|
|
660
|
+
# Fetch tools (raw MCP format)
|
|
661
|
+
mcp_tools = client.list_tools()
|
|
662
|
+
logger.info("Fetched %d tools from MCP server", len(mcp_tools))
|
|
663
|
+
|
|
664
|
+
# Push raw MCP tools to Spin (preserves inputSchema format)
|
|
665
|
+
spin_result = push_tools_to_spin(mcp_tools, spin_endpoint, timeout=timeout)
|
|
666
|
+
logger.info("Pushed %d tools to Spin server", spin_result.loaded)
|
|
667
|
+
|
|
668
|
+
# Also convert to ToolRegistry for local use/saving
|
|
669
|
+
tools = []
|
|
670
|
+
for tool_dict in mcp_tools:
|
|
671
|
+
try:
|
|
672
|
+
mcp_tool = MCPToolDefinition.model_validate(tool_dict)
|
|
673
|
+
tools.append(ToolDefinition.from_mcp(mcp_tool))
|
|
674
|
+
except Exception as e:
|
|
675
|
+
logger.warning("Failed to convert tool '%s': %s", tool_dict.get("name", "?"), e)
|
|
676
|
+
|
|
677
|
+
return ToolRegistry(tools=tools), spin_result
|