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.
Files changed (71) hide show
  1. deepfabric/__init__.py +70 -0
  2. deepfabric/__main__.py +6 -0
  3. deepfabric/auth.py +382 -0
  4. deepfabric/builders.py +303 -0
  5. deepfabric/builders_agent.py +1304 -0
  6. deepfabric/cli.py +1288 -0
  7. deepfabric/config.py +899 -0
  8. deepfabric/config_manager.py +251 -0
  9. deepfabric/constants.py +94 -0
  10. deepfabric/dataset_manager.py +534 -0
  11. deepfabric/error_codes.py +581 -0
  12. deepfabric/evaluation/__init__.py +47 -0
  13. deepfabric/evaluation/backends/__init__.py +32 -0
  14. deepfabric/evaluation/backends/ollama_backend.py +137 -0
  15. deepfabric/evaluation/backends/tool_call_parsers.py +409 -0
  16. deepfabric/evaluation/backends/transformers_backend.py +326 -0
  17. deepfabric/evaluation/evaluator.py +845 -0
  18. deepfabric/evaluation/evaluators/__init__.py +13 -0
  19. deepfabric/evaluation/evaluators/base.py +104 -0
  20. deepfabric/evaluation/evaluators/builtin/__init__.py +5 -0
  21. deepfabric/evaluation/evaluators/builtin/tool_calling.py +93 -0
  22. deepfabric/evaluation/evaluators/registry.py +66 -0
  23. deepfabric/evaluation/inference.py +155 -0
  24. deepfabric/evaluation/metrics.py +397 -0
  25. deepfabric/evaluation/parser.py +304 -0
  26. deepfabric/evaluation/reporters/__init__.py +13 -0
  27. deepfabric/evaluation/reporters/base.py +56 -0
  28. deepfabric/evaluation/reporters/cloud_reporter.py +195 -0
  29. deepfabric/evaluation/reporters/file_reporter.py +61 -0
  30. deepfabric/evaluation/reporters/multi_reporter.py +56 -0
  31. deepfabric/exceptions.py +67 -0
  32. deepfabric/factory.py +26 -0
  33. deepfabric/generator.py +1084 -0
  34. deepfabric/graph.py +545 -0
  35. deepfabric/hf_hub.py +214 -0
  36. deepfabric/kaggle_hub.py +219 -0
  37. deepfabric/llm/__init__.py +41 -0
  38. deepfabric/llm/api_key_verifier.py +534 -0
  39. deepfabric/llm/client.py +1206 -0
  40. deepfabric/llm/errors.py +105 -0
  41. deepfabric/llm/rate_limit_config.py +262 -0
  42. deepfabric/llm/rate_limit_detector.py +278 -0
  43. deepfabric/llm/retry_handler.py +270 -0
  44. deepfabric/metrics.py +212 -0
  45. deepfabric/progress.py +262 -0
  46. deepfabric/prompts.py +290 -0
  47. deepfabric/schemas.py +1000 -0
  48. deepfabric/spin/__init__.py +6 -0
  49. deepfabric/spin/client.py +263 -0
  50. deepfabric/spin/models.py +26 -0
  51. deepfabric/stream_simulator.py +90 -0
  52. deepfabric/tools/__init__.py +5 -0
  53. deepfabric/tools/defaults.py +85 -0
  54. deepfabric/tools/loader.py +87 -0
  55. deepfabric/tools/mcp_client.py +677 -0
  56. deepfabric/topic_manager.py +303 -0
  57. deepfabric/topic_model.py +20 -0
  58. deepfabric/training/__init__.py +35 -0
  59. deepfabric/training/api_key_prompt.py +302 -0
  60. deepfabric/training/callback.py +363 -0
  61. deepfabric/training/metrics_sender.py +301 -0
  62. deepfabric/tree.py +438 -0
  63. deepfabric/tui.py +1267 -0
  64. deepfabric/update_checker.py +166 -0
  65. deepfabric/utils.py +150 -0
  66. deepfabric/validation.py +143 -0
  67. deepfabric-4.4.0.dist-info/METADATA +702 -0
  68. deepfabric-4.4.0.dist-info/RECORD +71 -0
  69. deepfabric-4.4.0.dist-info/WHEEL +4 -0
  70. deepfabric-4.4.0.dist-info/entry_points.txt +2 -0
  71. 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