lollms-client 0.19.9__py3-none-any.whl → 0.20.1__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.

Potentially problematic release.


This version of lollms-client might be problematic. Click here for more details.

@@ -0,0 +1,535 @@
1
+ # File: lollms_client/mcp_bindings/standard_mcp/__init__.py
2
+
3
+ import pipmaster as pm
4
+
5
+ # Ensure critical dependencies for this binding are present.
6
+ # If pipmaster itself is missing, lollms_client is not correctly installed.
7
+ pm.ensure_packages(["mcp", "ascii-colors"])
8
+
9
+ import asyncio
10
+ import json
11
+ import threading
12
+ import sys
13
+ from contextlib import AsyncExitStack
14
+ from pathlib import Path
15
+ from typing import Optional, List, Dict, Any, Tuple
16
+
17
+ # These imports should now succeed if pipmaster did its job.
18
+ from lollms_client.lollms_mcp_binding import LollmsMCPBinding # Assuming this base class exists
19
+ from ascii_colors import ASCIIColors, trace_exception
20
+
21
+ # Attempt to import MCP library components.
22
+ try:
23
+ from mcp import ClientSession, StdioServerParameters
24
+ from mcp.client.stdio import stdio_client
25
+ from mcp import types # Use mcp.types for data structures
26
+ MCP_LIBRARY_AVAILABLE = True
27
+ ASCIIColors.green("Successfully imported MCP library components for StandardMCPBinding.")
28
+ except ImportError as e:
29
+ ASCIIColors.error(f"StandardMCPBinding: Critical MCP library components could not be imported even after pipmaster attempt: {e}")
30
+ ASCIIColors.error("Please check your Python environment, internet connection, and pip installation.")
31
+ ASCIIColors.error("StandardMCPBinding will be non-functional.")
32
+ ClientSession = None
33
+ StdioServerParameters = None
34
+ stdio_client = None
35
+ types = None # MCP types module unavailable
36
+ MCP_LIBRARY_AVAILABLE = False
37
+
38
+ # This variable is used by LollmsMCPBindingManager to identify the binding class.
39
+ BindingName = "StandardMCPBinding" # Must match the class name below
40
+ TOOL_NAME_SEPARATOR = "::"
41
+
42
+ class StandardMCPBinding(LollmsMCPBinding):
43
+ """
44
+ A LollmsMCPBinding to connect to multiple standard Model Context Protocol (MCP) servers.
45
+ This binding acts as an MCP client to these servers.
46
+ Each server is launched via a command, communicates over stdio, and is identified by a unique alias.
47
+ Tool names are prefixed with 'server_alias::' for disambiguation.
48
+ """
49
+
50
+ def __init__(self,
51
+ initial_servers: Optional[Dict[str, Dict[str, Any]]] = None,
52
+ **other_config_params: Any):
53
+ super().__init__(binding_name="standard_mcp")
54
+
55
+ self.config = {"initial_servers": initial_servers if initial_servers else {}}
56
+ self.config.update(other_config_params)
57
+
58
+ self._server_configs: Dict[str, Dict[str, Any]] = {}
59
+ # Type hint with ClientSession, actual obj if MCP_LIBRARY_AVAILABLE
60
+ self._mcp_sessions: Dict[str, ClientSession] = {} # type: ignore
61
+ self._exit_stacks: Dict[str, AsyncExitStack] = {}
62
+ self._discovered_tools_cache: Dict[str, List[Dict[str, Any]]] = {}
63
+ self._server_locks: Dict[str, threading.Lock] = {}
64
+ self._initialization_status: Dict[str, bool] = {}
65
+ self._loop: Optional[asyncio.AbstractEventLoop] = None
66
+ self._thread: Optional[threading.Thread] = None
67
+
68
+ if not MCP_LIBRARY_AVAILABLE:
69
+ ASCIIColors.error(f"{self.binding_name}: Cannot initialize; MCP library components are missing.")
70
+ return # Binding remains in a non-functional state
71
+
72
+ self._loop = asyncio.new_event_loop()
73
+ self._thread = threading.Thread(target=self._start_event_loop, daemon=True,
74
+ name=f"{self.binding_name}EventLoopThread")
75
+ self._thread.start()
76
+ ASCIIColors.info(f"{self.binding_name}: Event loop thread started.")
77
+
78
+ if initial_servers:
79
+ for alias, config_data in initial_servers.items():
80
+ if isinstance(config_data, dict):
81
+ # Ensure command is a list
82
+ command = config_data.get("command")
83
+ if isinstance(command, str): # if command is a single string, convert to list
84
+ command = command.split()
85
+
86
+ self.add_server(
87
+ alias=alias,
88
+ command=command, # type: ignore
89
+ cwd=config_data.get("cwd"),
90
+ env=config_data.get("env")
91
+ )
92
+ else:
93
+ ASCIIColors.warning(f"{self.binding_name}: Invalid configuration for server alias '{alias}' in 'initial_servers'. Expected a dictionary.")
94
+
95
+ def _start_event_loop(self):
96
+ if not self._loop: return
97
+ asyncio.set_event_loop(self._loop)
98
+ try:
99
+ self._loop.run_forever()
100
+ finally:
101
+ # Cleanup tasks before closing the loop
102
+ if hasattr(asyncio, 'all_tasks'): # Python 3.7+
103
+ pending = asyncio.all_tasks(self._loop)
104
+ else: # Python 3.6
105
+ pending = asyncio.Task.all_tasks(self._loop) # type: ignore
106
+
107
+ if pending:
108
+ self._loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True))
109
+
110
+ if self._loop.is_running():
111
+ self._loop.stop()
112
+
113
+ if not self._loop.is_closed():
114
+ if sys.platform == "win32" and isinstance(self._loop, asyncio.ProactorEventLoop): # type: ignore
115
+ self._loop.call_soon(self._loop.stop)
116
+ try:
117
+ # This run_until_complete might be problematic if called from non-loop thread after stop
118
+ # but often necessary for proactor loop cleanup on Windows
119
+ self._loop.run_until_complete(asyncio.sleep(0.1))
120
+ except RuntimeError as e:
121
+ if "cannot be called from a different thread" not in str(e):
122
+ ASCIIColors.warning(f"{self.binding_name}: Minor issue during proactor loop sleep: {e}")
123
+ self._loop.close()
124
+ ASCIIColors.info(f"{self.binding_name}: Asyncio event loop has stopped and closed.")
125
+
126
+
127
+ def _run_async_task(self, coro, timeout: Optional[float] = None) -> Any:
128
+ if not MCP_LIBRARY_AVAILABLE or not self._loop or not self._loop.is_running() or not self._thread or not self._thread.is_alive():
129
+ raise RuntimeError(f"{self.binding_name}'s event loop is not operational or MCP library is missing.")
130
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
131
+ try:
132
+ return future.result(timeout=timeout)
133
+ except TimeoutError:
134
+ future.cancel() # Attempt to cancel the coroutine
135
+ raise
136
+ except Exception:
137
+ raise
138
+
139
+ def add_server(self, alias: str, command: List[str], cwd: Optional[str] = None, env: Optional[Dict[str, str]] = None) -> bool:
140
+ if not MCP_LIBRARY_AVAILABLE:
141
+ ASCIIColors.error(f"{self.binding_name}: Cannot add server '{alias}', MCP library is not available.")
142
+ return False
143
+
144
+ if not alias or not isinstance(alias, str):
145
+ ASCIIColors.error(f"{self.binding_name}: Server alias must be a non-empty string.")
146
+ return False
147
+ if not command or not isinstance(command, list) or not all(isinstance(c, str) for c in command) or not command[0]:
148
+ ASCIIColors.error(f"{self.binding_name}: Server command for '{alias}' must be a non-empty list of strings (e.g., ['python', 'server.py']).")
149
+ return False
150
+
151
+ if alias in self._server_configs:
152
+ ASCIIColors.warning(f"{self.binding_name}: Reconfiguring server '{alias}'. Existing connection (if any) will be closed.")
153
+ self.remove_server(alias, silent=True)
154
+
155
+ self._server_configs[alias] = {"command": command, "cwd": cwd, "env": env}
156
+ self._server_locks[alias] = threading.Lock()
157
+ self._initialization_status[alias] = False
158
+ self._discovered_tools_cache[alias] = [] # Initialize cache for the new server
159
+ ASCIIColors.info(f"{self.binding_name}: Server '{alias}' configured with command: {command}")
160
+
161
+ if "initial_servers" not in self.config:
162
+ self.config["initial_servers"] = {}
163
+ if isinstance(self.config["initial_servers"], dict): # Ensure it's a dict
164
+ self.config["initial_servers"][alias] = self._server_configs[alias]
165
+ return True
166
+
167
+ async def _close_server_connection_async(self, alias: str):
168
+ exit_stack_to_close = self._exit_stacks.pop(alias, None)
169
+ # Pop session and status immediately to reflect desired state
170
+ self._mcp_sessions.pop(alias, None)
171
+ self._initialization_status[alias] = False
172
+
173
+ if exit_stack_to_close:
174
+ ASCIIColors.info(f"{self.binding_name}: Attempting to close MCP connection for server '{alias}'...")
175
+ try:
176
+ await exit_stack_to_close.aclose()
177
+ ASCIIColors.info(f"{self.binding_name}: MCP connection for '{alias}' resources released via aclose.")
178
+ except RuntimeError as e:
179
+ if "Attempted to exit cancel scope in a different task" in str(e):
180
+ ASCIIColors.warning(f"{self.binding_name}: Known anyio task ownership issue during close for '{alias}': {e}.")
181
+ ASCIIColors.warning(f"{self.binding_name}: Underlying MCP client resources for '{alias}' may not have been fully cleaned up due to this anyio constraint.")
182
+ # At this point, the stdio process might still be running.
183
+ # Further action (like trying to kill the process) is outside the scope of AsyncExitStack.
184
+ else:
185
+ # Reraise other RuntimeErrors or handle them
186
+ trace_exception(e)
187
+ ASCIIColors.error(f"{self.binding_name}: Unexpected RuntimeError closing MCP connection for '{alias}': {e}")
188
+ except Exception as e:
189
+ trace_exception(e)
190
+ ASCIIColors.error(f"{self.binding_name}: General error closing MCP connection for '{alias}': {e}")
191
+ # else:
192
+ # ASCIIColors.debug(f"{self.binding_name}: No active exit stack found for server '{alias}' to close (already closed or never fully initialized).")
193
+
194
+ def remove_server(self, alias: str, silent: bool = False):
195
+ if not MCP_LIBRARY_AVAILABLE:
196
+ if not silent: ASCIIColors.error(f"{self.binding_name}: Cannot remove server '{alias}', MCP library issues persist."); return
197
+
198
+ if alias not in self._server_configs:
199
+ if not silent: ASCIIColors.warning(f"{self.binding_name}: Server '{alias}' not found for removal.")
200
+ return
201
+
202
+ if not silent: ASCIIColors.info(f"{self.binding_name}: Removing server '{alias}'.")
203
+
204
+ if self._initialization_status.get(alias) or alias in self._exit_stacks or alias in self._mcp_sessions:
205
+ try:
206
+ self._run_async_task(self._close_server_connection_async(alias), timeout=10.0)
207
+ except RuntimeError as e:
208
+ if not silent: ASCIIColors.warning(f"{self.binding_name}: Could not run async close for '{alias}' (event loop issue?): {e}")
209
+ except Exception as e:
210
+ if not silent: ASCIIColors.error(f"{self.binding_name}: Exception during async close for '{alias}': {e}")
211
+
212
+ self._server_configs.pop(alias, None)
213
+ self._server_locks.pop(alias, None)
214
+ self._initialization_status.pop(alias, None)
215
+ self._discovered_tools_cache.pop(alias, None)
216
+ if "initial_servers" in self.config and isinstance(self.config["initial_servers"], dict) and alias in self.config["initial_servers"]:
217
+ self.config["initial_servers"].pop(alias)
218
+ if not silent: ASCIIColors.info(f"{self.binding_name}: Server '{alias}' removed.")
219
+
220
+ async def _initialize_connection_async(self, alias: str) -> bool:
221
+ if not MCP_LIBRARY_AVAILABLE or not types or not ClientSession or not StdioServerParameters or not stdio_client:
222
+ ASCIIColors.error(f"{self.binding_name}: MCP library components (types, ClientSession, etc.) not available. Cannot initialize '{alias}'.")
223
+ return False
224
+ if self._initialization_status.get(alias): return True
225
+ if alias not in self._server_configs:
226
+ ASCIIColors.error(f"{self.binding_name}: No configuration for server alias '{alias}'. Cannot initialize.")
227
+ return False
228
+
229
+ config = self._server_configs[alias]
230
+ ASCIIColors.info(f"{self.binding_name}: Initializing MCP connection for server '{alias}'...")
231
+ try:
232
+ if alias in self._exit_stacks: # Should ideally be cleaned up if a previous attempt failed
233
+ old_stack = self._exit_stacks.pop(alias)
234
+ await old_stack.aclose()
235
+
236
+ exit_stack = AsyncExitStack()
237
+ self._exit_stacks[alias] = exit_stack
238
+
239
+ server_params = StdioServerParameters(
240
+ command=config["command"][0],
241
+ args=config["command"][1:],
242
+ cwd=Path(config["cwd"]) if config["cwd"] else None,
243
+ env=config["env"]
244
+ )
245
+ read_stream, write_stream = await exit_stack.enter_async_context(stdio_client(server_params))
246
+
247
+ # CORRECTED: Removed client_name from ClientSession constructor
248
+ session = await exit_stack.enter_async_context(ClientSession(read_stream, write_stream))
249
+
250
+ await session.initialize() # This is where client capabilities/info might be exchanged
251
+ self._mcp_sessions[alias] = session
252
+ self._initialization_status[alias] = True
253
+ ASCIIColors.green(f"{self.binding_name}: Successfully initialized MCP session for server '{alias}'.")
254
+ await self._refresh_tools_cache_async(alias)
255
+ return True
256
+ except Exception as e:
257
+ trace_exception(e)
258
+ ASCIIColors.error(f"{self.binding_name}: Failed to initialize MCP connection for '{alias}': {e}")
259
+ if alias in self._exit_stacks:
260
+ current_stack = self._exit_stacks.pop(alias)
261
+ try:
262
+ await current_stack.aclose()
263
+ except Exception as e_close:
264
+ ASCIIColors.error(f"{self.binding_name}: Error during cleanup after failed init for '{alias}': {e_close}")
265
+ self._initialization_status[alias] = False
266
+ self._mcp_sessions.pop(alias, None)
267
+ return False
268
+
269
+ def _ensure_server_initialized_sync(self, alias: str, timeout: float = 30.0):
270
+ if not MCP_LIBRARY_AVAILABLE or not self._loop or not types:
271
+ raise ConnectionError(f"{self.binding_name}: MCP library/event loop/types module not available. Cannot initialize server '{alias}'.")
272
+
273
+ if alias not in self._server_configs:
274
+ raise ValueError(f"{self.binding_name}: Server alias '{alias}' is not configured.")
275
+
276
+ lock = self._server_locks.get(alias)
277
+ if not lock:
278
+ ASCIIColors.error(f"{self.binding_name}: Internal error - No lock for server '{alias}'. Creating one now.")
279
+ self._server_locks[alias] = threading.Lock()
280
+ lock = self._server_locks[alias]
281
+
282
+
283
+ with lock:
284
+ if not self._initialization_status.get(alias):
285
+ ASCIIColors.info(f"{self.binding_name}: Connection for '{alias}' not initialized. Attempting initialization...")
286
+ try:
287
+ success = self._run_async_task(self._initialize_connection_async(alias), timeout=timeout)
288
+ if not success:
289
+ # If init itself reports failure (e.g. returns False from _initialize_connection_async)
290
+ self._discovered_tools_cache[alias] = [] # CLEAR CACHE ON FAILURE
291
+ raise ConnectionError(f"MCP init for '{alias}' reported failure.")
292
+ except TimeoutError:
293
+ self._discovered_tools_cache[alias] = [] # CLEAR CACHE ON FAILURE
294
+ raise ConnectionError(f"MCP init for '{alias}' timed out.")
295
+ except Exception as e: # Other exceptions during run_async_task
296
+ self._discovered_tools_cache[alias] = [] # CLEAR CACHE ON FAILURE
297
+ raise ConnectionError(f"MCP init for '{alias}' failed: {e}")
298
+
299
+ if not self._initialization_status.get(alias) or alias not in self._mcp_sessions:
300
+ # This means init was thought to be successful by the lock block, but status is bad
301
+ # This case might indicate a race or an issue if _initialize_connection_async doesn't set status correctly on all paths
302
+ self._discovered_tools_cache[alias] = [] # Also clear here as a safeguard
303
+ raise ConnectionError(f"MCP Session for '{alias}' not valid post-init attempt, despite no immediate error.")
304
+
305
+ async def _refresh_tools_cache_async(self, alias: str):
306
+ if not MCP_LIBRARY_AVAILABLE or not types:
307
+ ASCIIColors.error(f"{self.binding_name}: MCP library or types module not available. Cannot refresh tools for '{alias}'.")
308
+ return
309
+ if not self._initialization_status.get(alias) or alias not in self._mcp_sessions:
310
+ ASCIIColors.warning(f"{self.binding_name}: Server '{alias}' not initialized or no session. Cannot refresh tools.")
311
+ return
312
+
313
+ session = self._mcp_sessions[alias]
314
+ ASCIIColors.info(f"{self.binding_name}: Refreshing tools cache for server '{alias}'...")
315
+ try:
316
+ list_tools_result = await session.list_tools() # Expected to be types.ListToolsResult
317
+ current_server_tools = []
318
+ if list_tools_result and list_tools_result.tools:
319
+ for tool_obj in list_tools_result.tools: # tool_obj is expected to be types.Tool
320
+ # --- DEBUGGING ---
321
+ # print(f"DEBUG: tool_obj type: {type(tool_obj)}")
322
+ # print(f"DEBUG: tool_obj dir: {dir(tool_obj)}")
323
+ # if hasattr(tool_obj, 'model_fields'): print(f"DEBUG: tool_obj fields: {tool_obj.model_fields.keys()}") # Pydantic v2
324
+ # elif hasattr(tool_obj, '__fields__'): print(f"DEBUG: tool_obj fields: {tool_obj.__fields__.keys()}") # Pydantic v1
325
+ # if hasattr(tool_obj, 'model_dump_json'): print(f"DEBUG: tool_obj JSON: {tool_obj.model_dump_json(indent=2)}")
326
+ # elif hasattr(tool_obj, 'json'): print(f"DEBUG: tool_obj JSON: {tool_obj.json(indent=2)}")
327
+ # --- END DEBUGGING ---
328
+
329
+ input_schema_dict = {}
330
+ # Try accessing with 'inputSchema' (camelCase) or check other potential names based on debug output
331
+ tool_input_schema = None
332
+ if hasattr(tool_obj, 'inputSchema'): # Common JSON convention
333
+ tool_input_schema = tool_obj.inputSchema
334
+ elif hasattr(tool_obj, 'input_schema'): # Python convention
335
+ tool_input_schema = tool_obj.input_schema
336
+ # Add more elif for other possibilities if revealed by debugging
337
+
338
+ if tool_input_schema: # Check if the schema object itself exists and is not None
339
+ # tool_input_schema is expected to be types.InputSchema | None
340
+ # or a Pydantic model that has model_dump
341
+ if hasattr(tool_input_schema, 'model_dump'):
342
+ input_schema_dict = tool_input_schema.model_dump(mode='json', exclude_none=True)
343
+ else:
344
+ # If it's not a Pydantic model but some other dict-like structure
345
+ # This part might need adjustment based on what tool_input_schema actually is
346
+ ASCIIColors.warning(f"{self.binding_name}: input schema for tool '{tool_obj.name}' on '{alias}' is not a Pydantic model with model_dump. Type: {type(tool_input_schema)}")
347
+ if isinstance(tool_input_schema, dict):
348
+ input_schema_dict = tool_input_schema
349
+ # else: leave it as empty dict
350
+
351
+ tool_dict = {
352
+ "name": tool_obj.name,
353
+ "description": tool_obj.description or "",
354
+ "input_schema": input_schema_dict
355
+ }
356
+ current_server_tools.append(tool_dict)
357
+ self._discovered_tools_cache[alias] = current_server_tools
358
+ ASCIIColors.green(f"{self.binding_name}: Tools cache for '{alias}' refreshed. Found {len(current_server_tools)} tools.")
359
+ except Exception as e:
360
+ trace_exception(e)
361
+ ASCIIColors.error(f"{self.binding_name}: Error refreshing tools cache for '{alias}': {e}")
362
+ self._discovered_tools_cache[alias] = [] # Clear cache on error
363
+
364
+ def discover_tools(self, specific_tool_names: Optional[List[str]]=None, force_refresh: bool=False, timeout_per_server: float=10.0, **kwargs) -> List[Dict[str, Any]]:
365
+ if not MCP_LIBRARY_AVAILABLE or not self._loop or not types:
366
+ ASCIIColors.warning(f"{self.binding_name}: Cannot discover tools, MCP library, event loop, or types module not available.")
367
+ return []
368
+
369
+ stn = kwargs.get('specific_tool_names', specific_tool_names)
370
+ fr = kwargs.get('force_refresh', force_refresh)
371
+ tps = kwargs.get('timeout_per_server', timeout_per_server)
372
+
373
+ all_tools: List[Dict[str, Any]] = []
374
+ active_aliases = list(self._server_configs.keys())
375
+
376
+ for alias in active_aliases:
377
+ try:
378
+ if force_refresh: # Explicitly clear before ensuring init if forcing
379
+ ASCIIColors.yellow(f"{self.binding_name}: Force refresh - clearing cache for '{alias}' before init.")
380
+ self._discovered_tools_cache[alias] = []
381
+
382
+ self._ensure_server_initialized_sync(alias, timeout=tps)
383
+
384
+ # If force_refresh OR if server is initialized but cache is empty/stale
385
+ if force_refresh or (self._initialization_status.get(alias) and not self._discovered_tools_cache.get(alias)):
386
+ ASCIIColors.info(f"{self.binding_name}: Refreshing tools for '{alias}' (force_refresh={force_refresh}, cache_empty={not self._discovered_tools_cache.get(alias)}).")
387
+ self._run_async_task(self._refresh_tools_cache_async(alias), timeout=tps)
388
+
389
+ if fr or (self._initialization_status.get(alias) and not self._discovered_tools_cache.get(alias)):
390
+ ASCIIColors.info(f"{self.binding_name}: Force refreshing tools for '{alias}' or cache is empty.")
391
+ self._run_async_task(self._refresh_tools_cache_async(alias), timeout=tps)
392
+
393
+ for tool_data in self._discovered_tools_cache.get(alias, []):
394
+ prefixed_tool_data = tool_data.copy()
395
+ prefixed_tool_data["name"] = f"{alias}{TOOL_NAME_SEPARATOR}{tool_data['name']}"
396
+ all_tools.append(prefixed_tool_data)
397
+ except ConnectionError as e:
398
+ ASCIIColors.error(f"{self.binding_name}: Connection problem with server '{alias}' during tool discovery: {e}")
399
+ except Exception as e:
400
+ trace_exception(e)
401
+ ASCIIColors.error(f"{self.binding_name}: Unexpected problem with server '{alias}' during tool discovery: {e}")
402
+
403
+ if stn:
404
+ return [t for t in all_tools if t.get("name") in stn]
405
+ return all_tools
406
+
407
+ def _parse_tool_name(self, prefixed_tool_name: str) -> Optional[Tuple[str, str]]:
408
+ parts = prefixed_tool_name.split(TOOL_NAME_SEPARATOR, 1)
409
+ if len(parts) == 2:
410
+ return parts[0], parts[1]
411
+ ASCIIColors.warning(f"{self.binding_name}: Tool name '{prefixed_tool_name}' is not in the expected 'alias{TOOL_NAME_SEPARATOR}tool' format.")
412
+ return None
413
+
414
+ async def _execute_tool_async(self, server_alias: str, actual_tool_name: str, params: Dict[str, Any]) -> Dict[str, Any]:
415
+ if not MCP_LIBRARY_AVAILABLE or not types:
416
+ error_msg = f"{self.binding_name}: MCP library or types module not available. Cannot execute tool '{actual_tool_name}' on '{server_alias}'."
417
+ ASCIIColors.error(error_msg)
418
+ return {"error": error_msg, "status_code": 503}
419
+
420
+ if not self._initialization_status.get(server_alias) or server_alias not in self._mcp_sessions:
421
+ error_msg = f"Server '{server_alias}' not initialized or session lost. Cannot execute tool '{actual_tool_name}'."
422
+ ASCIIColors.error(f"{self.binding_name}: {error_msg}")
423
+ return {"error": error_msg, "status_code": 503}
424
+
425
+ session = self._mcp_sessions[server_alias]
426
+ # Use a more careful way to log params if they can be very large or sensitive
427
+ params_log = {k: (v[:100] + '...' if isinstance(v, str) and len(v) > 100 else v) for k,v in params.items()}
428
+ ASCIIColors.info(f"{self.binding_name}: Executing MCP tool '{actual_tool_name}' on server '{server_alias}' with params: {json.dumps(params_log)}")
429
+ try:
430
+ # call_tool returns types.CallToolResult
431
+ mcp_call_result = await session.call_tool(name=actual_tool_name, arguments=params)
432
+
433
+ output_parts = []
434
+ if mcp_call_result and mcp_call_result.content: # content is List[types.ContentPart]
435
+ for content_part in mcp_call_result.content:
436
+ if isinstance(content_part, types.TextContent) and hasattr(content_part, 'text') and content_part.text is not None:
437
+ output_parts.append(content_part.text)
438
+
439
+ if not output_parts:
440
+ ASCIIColors.info(f"{self.binding_name}: Tool '{actual_tool_name}' on '{server_alias}' executed but returned no textual content.")
441
+ return {"output": {"message": "Tool executed successfully but returned no textual content."}, "status_code": 200}
442
+
443
+ combined_output_str = "\n".join(output_parts)
444
+ ASCIIColors.success(f"{self.binding_name}: Tool '{actual_tool_name}' on '{server_alias}' executed. Raw output (first 200 chars): '{combined_output_str[:200]}'")
445
+
446
+ try:
447
+ parsed_output = json.loads(combined_output_str)
448
+ return {"output": parsed_output, "status_code": 200}
449
+ except json.JSONDecodeError:
450
+ return {"output": combined_output_str, "status_code": 200}
451
+
452
+ except Exception as e:
453
+ trace_exception(e)
454
+ error_msg = f"Error executing tool '{actual_tool_name}' on server '{server_alias}': {str(e)}"
455
+ ASCIIColors.error(f"{self.binding_name}: {error_msg}")
456
+ return {"error": error_msg, "status_code": 500}
457
+
458
+ def execute_tool(self, tool_name: str, params: Dict[str, Any], **kwargs) -> Dict[str, Any]:
459
+ if not MCP_LIBRARY_AVAILABLE or not self._loop or not types:
460
+ error_msg = f"{self.binding_name}: MCP support (library, event loop, or types module) not available. Cannot execute tool '{tool_name}'."
461
+ ASCIIColors.warning(error_msg)
462
+ return {"error": error_msg, "status_code": 503}
463
+
464
+ timeout = float(kwargs.get('timeout', 60.0))
465
+
466
+ parsed_name = self._parse_tool_name(tool_name)
467
+ if not parsed_name:
468
+ return {"error": f"Invalid tool name format for {self.binding_name}: '{tool_name}'. Expected 'alias{TOOL_NAME_SEPARATOR}toolname'.", "status_code": 400}
469
+
470
+ server_alias, actual_tool_name = parsed_name
471
+
472
+ if server_alias not in self._server_configs:
473
+ return {"error": f"Server alias '{server_alias}' (from tool name '{tool_name}') is not configured.", "status_code": 404}
474
+
475
+ try:
476
+ init_timeout = min(timeout, 30.0)
477
+ self._ensure_server_initialized_sync(server_alias, timeout=init_timeout)
478
+ except ConnectionError as e:
479
+ return {"error": f"{self.binding_name}: Connection or configuration issue for server '{server_alias}': {e}", "status_code": 503}
480
+ except Exception as e:
481
+ trace_exception(e)
482
+ return {"error": f"{self.binding_name}: Failed to ensure server '{server_alias}' is initialized: {e}", "status_code": 500}
483
+
484
+ try:
485
+ return self._run_async_task(self._execute_tool_async(server_alias, actual_tool_name, params), timeout=timeout)
486
+ except TimeoutError:
487
+ return {"error": f"{self.binding_name}: Tool '{actual_tool_name}' on server '{server_alias}' timed out after {timeout} seconds.", "status_code": 504}
488
+ except RuntimeError as e:
489
+ return {"error": f"{self.binding_name}: Runtime error executing tool '{actual_tool_name}' on '{server_alias}': {e}", "status_code": 500}
490
+ except Exception as e:
491
+ trace_exception(e)
492
+ return {"error": f"{self.binding_name}: An unexpected error occurred while running MCP tool '{actual_tool_name}' on server '{server_alias}': {e}", "status_code": 500}
493
+
494
+ def close(self):
495
+ ASCIIColors.info(f"{self.binding_name}: Initiating shutdown process...")
496
+
497
+ if hasattr(self, '_server_configs') and self._server_configs:
498
+ active_aliases = list(self._server_configs.keys())
499
+ if active_aliases:
500
+ ASCIIColors.info(f"{self.binding_name}: Closing connections for servers: {active_aliases}")
501
+ for alias in active_aliases:
502
+ self.remove_server(alias, silent=True)
503
+
504
+ if hasattr(self, '_loop') and self._loop:
505
+ if self._loop.is_running():
506
+ ASCIIColors.info(f"{self.binding_name}: Requesting event loop to stop.")
507
+ self._loop.call_soon_threadsafe(self._loop.stop)
508
+
509
+ if hasattr(self, '_thread') and self._thread and self._thread.is_alive():
510
+ ASCIIColors.info(f"{self.binding_name}: Waiting for event loop thread to join...")
511
+ self._thread.join(timeout=10.0)
512
+ if self._thread.is_alive():
513
+ ASCIIColors.warning(f"{self.binding_name}: Event loop thread did not terminate cleanly after 10 seconds.")
514
+ else:
515
+ ASCIIColors.info(f"{self.binding_name}: Event loop thread joined successfully.")
516
+
517
+ ASCIIColors.info(f"{self.binding_name}: Binding closed.")
518
+
519
+ def __del__(self):
520
+ # Check if attributes relevant to closing exist to prevent errors if __init__ failed early
521
+ needs_close = False
522
+ if hasattr(self, '_loop') and self._loop and (self._loop.is_running() or not self._loop.is_closed()):
523
+ needs_close = True
524
+ if hasattr(self, '_thread') and self._thread and self._thread.is_alive():
525
+ needs_close = True
526
+ if hasattr(self, '_server_configs') and self._server_configs: # Check if there are any servers to close
527
+ needs_close = True
528
+
529
+ if needs_close:
530
+ ASCIIColors.warning(f"{self.binding_name}: __del__ called; attempting to close resources. Explicit .close() is recommended for reliability.")
531
+ try:
532
+ self.close()
533
+ except Exception as e:
534
+ # __del__ should not raise exceptions
535
+ ASCIIColors.error(f"{self.binding_name}: Error during __del__ cleanup: {e}")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lollms_client
3
- Version: 0.19.9
3
+ Version: 0.20.1
4
4
  Summary: A client library for LoLLMs generate endpoint
5
5
  Author-email: ParisNeo <parisneoai@gmail.com>
6
6
  License: Apache Software License
@@ -1,8 +1,11 @@
1
+ examples/external_mcp.py,sha256=swx1KCOz6jk8jGTAycq-xu7GXPAhRMDe1x--SKocugE,13371
1
2
  examples/function_calling_with_local_custom_mcp.py,sha256=g6wOFRB8-p9Cv7hKmQaGzPvtMX3H77gas01QVNEOduM,12407
2
3
  examples/generate_a_benchmark_for_safe_store.py,sha256=bkSt0mrpNsN0krZAUShm0jgVM1ukrPpjI7VwSgcNdSA,3974
3
4
  examples/generate_text_with_multihop_rag_example.py,sha256=riEyVYo97r6ZYdySL-NJkRhE4MnpwbZku1sN8RNvbvs,11519
4
5
  examples/internet_search_with_rag.py,sha256=cbUoGgY3rxZpQ5INoaA0Nhm0cutii-2AQ9WCz71Ch3o,12369
5
6
  examples/local_mcp.py,sha256=w40dgayvHYe01yvekEE0LjcbkpwKjWwJ-9v4_wGYsUk,9113
7
+ examples/openai_mcp.py,sha256=7IEnPGPXZgYZyiES_VaUbQ6viQjenpcUxGiHE-pGeFY,11060
8
+ examples/run_standard_mcp_example.py,sha256=GSZpaACPf3mDPsjA8esBQVUsIi7owI39ca5avsmvCxA,9419
6
9
  examples/simple_text_gen_test.py,sha256=RoX9ZKJjGMujeep60wh5WT_GoBn0O9YKJY6WOy-ZmOc,8710
7
10
  examples/simple_text_gen_with_image_test.py,sha256=rR1O5Prcb52UHtJ3c6bv7VuTd1cvbkr5aNZU-v-Rs3Y,9263
8
11
  examples/text_2_audio.py,sha256=MfL4AH_NNwl6m0I0ywl4BXRZJ0b9Y_9fRqDIe6O-Sbw,3523
@@ -20,9 +23,9 @@ examples/personality_test/chat_test.py,sha256=o2jlpoddFc-T592iqAiA29xk3x27KsdK5D
20
23
  examples/personality_test/chat_with_aristotle.py,sha256=4X_fwubMpd0Eq2rCReS2bgVlUoAqJprjkLXk2Jz6pXU,1774
21
24
  examples/personality_test/tesks_test.py,sha256=7LIiwrEbva9WWZOLi34fsmCBN__RZbPpxoUOKA_AtYk,1924
22
25
  examples/test_local_models/local_chat.py,sha256=slakja2zaHOEAUsn2tn_VmI4kLx6luLBrPqAeaNsix8,456
23
- lollms_client/__init__.py,sha256=ZuMTyKsGxnpozXbTiKEBlP7iMSdWHqlU2mAw_Jp1NY8,910
26
+ lollms_client/__init__.py,sha256=lb304rAtJB-3E0wpgeistSN8SLomx55y2P1VXAJndqU,910
24
27
  lollms_client/lollms_config.py,sha256=goEseDwDxYJf3WkYJ4IrLXwg3Tfw73CXV2Avg45M_hE,21876
25
- lollms_client/lollms_core.py,sha256=3SxNX4cUgP3zN8x0TYv-G5XeS8WhoSiyss69qmjweRE,112862
28
+ lollms_client/lollms_core.py,sha256=dRLTAjNTr8WesMUD3EZ-wQfkvH1V4kWDTPpv5979ieY,114068
26
29
  lollms_client/lollms_discussion.py,sha256=EV90dIgw8a-f-82vB2GspR60RniYz7WnBmAWSIg5mW0,2158
27
30
  lollms_client/lollms_js_analyzer.py,sha256=01zUvuO2F_lnUe_0NLxe1MF5aHE1hO8RZi48mNPv-aw,8361
28
31
  lollms_client/lollms_llm_binding.py,sha256=bdElz_IBx0zZ-85YTT1fyY_mSoHo46tKIMiHYJlKCkM,9809
@@ -50,6 +53,8 @@ lollms_client/mcp_bindings/local_mcp/default_tools/file_writer/file_writer.py,sh
50
53
  lollms_client/mcp_bindings/local_mcp/default_tools/generate_image_from_prompt/generate_image_from_prompt.py,sha256=THtZsMxNnXZiBdkwoBlfbWY2C5hhDdmPtnM-8cSKN6s,9488
51
54
  lollms_client/mcp_bindings/local_mcp/default_tools/internet_search/internet_search.py,sha256=PLC31-D04QKTOTb1uuCHnrAlpysQjsk89yIJngK0VGc,4586
52
55
  lollms_client/mcp_bindings/local_mcp/default_tools/python_interpreter/python_interpreter.py,sha256=McDCBVoVrMDYgU7EYtyOY7mCk1uEeTea0PSD69QqDsQ,6228
56
+ lollms_client/mcp_bindings/remote_mcp/__init__.py,sha256=L7J_CvpF5ydu_eBVNuxUPViedDgI5jbSqPSy8rQTtYU,13170
57
+ lollms_client/mcp_bindings/standard_mcp/__init__.py,sha256=zpF4h8cTUxoERI-xcVjmS_V772LK0V4jegjz2k1PK98,31658
53
58
  lollms_client/stt_bindings/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
54
59
  lollms_client/stt_bindings/lollms/__init__.py,sha256=jBz3285atdPRqQe9ZRrb-AvjqKRB4f8tjLXjma0DLfE,6082
55
60
  lollms_client/stt_bindings/whisper/__init__.py,sha256=vrua7fLwDId9_WiH4y2gXOE0hy3Gr2Ig-z5ScIT2bHI,15447
@@ -70,8 +75,8 @@ lollms_client/tts_bindings/piper_tts/__init__.py,sha256=0IEWG4zH3_sOkSb9WbZzkeV5
70
75
  lollms_client/tts_bindings/xtts/__init__.py,sha256=FgcdUH06X6ZR806WQe5ixaYx0QoxtAcOgYo87a2qxYc,18266
71
76
  lollms_client/ttv_bindings/__init__.py,sha256=UZ8o2izQOJLQgtZ1D1cXoNST7rzqW22rL2Vufc7ddRc,3141
72
77
  lollms_client/ttv_bindings/lollms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
73
- lollms_client-0.19.9.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
74
- lollms_client-0.19.9.dist-info/METADATA,sha256=ODlUMX37ZeZ1tJEPgJyc2yk40Dac-iivpGAM5IjPxSI,13374
75
- lollms_client-0.19.9.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
76
- lollms_client-0.19.9.dist-info/top_level.txt,sha256=NI_W8S4OYZvJjb0QWMZMSIpOrYzpqwPGYaklhyWKH2w,23
77
- lollms_client-0.19.9.dist-info/RECORD,,
78
+ lollms_client-0.20.1.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
79
+ lollms_client-0.20.1.dist-info/METADATA,sha256=tR7Z3TVnt6MQOlrjMEtRJ9-BNnYAby4TWV0vfPLgiZo,13374
80
+ lollms_client-0.20.1.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
81
+ lollms_client-0.20.1.dist-info/top_level.txt,sha256=NI_W8S4OYZvJjb0QWMZMSIpOrYzpqwPGYaklhyWKH2w,23
82
+ lollms_client-0.20.1.dist-info/RECORD,,