lollms-client 0.20.4__py3-none-any.whl → 0.20.6__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,226 @@
1
+ # File: run_lollms_client_with_mcp_example.py
2
+
3
+ import sys
4
+ import os
5
+ import shutil
6
+ from pathlib import Path
7
+ import json
8
+ from lollms_client import LollmsClient
9
+ import subprocess
10
+ # --- Dynamically adjust Python path to find lollms_client ---
11
+ # This assumes the example script is in a directory, and 'lollms_client' is
12
+ # in a sibling directory or a known relative path. Adjust as needed.
13
+ # For example, if script is in 'lollms_client/examples/' and lollms_client code is in 'lollms_client/'
14
+ # then the parent of the script's parent is the project root.
15
+
16
+ # Get the directory of the current script
17
+ current_script_dir = Path(__file__).resolve().parent
18
+
19
+ # Option 1: If lollms_client is in the parent directory of this script's directory
20
+ # (e.g. script is in 'project_root/examples' and lollms_client is in 'project_root/lollms_client')
21
+ # project_root = current_script_dir.parent
22
+ # lollms_client_path = project_root / "lollms_client" # Assuming this is where lollms_client.py and bindings are
23
+
24
+ # Option 2: If lollms_client package is directly one level up
25
+ # (e.g. script is in 'lollms_client/examples' and lollms_client package is 'lollms_client')
26
+ project_root_for_lollms_client = current_script_dir.parent
27
+ if str(project_root_for_lollms_client) not in sys.path:
28
+ sys.path.insert(0, str(project_root_for_lollms_client))
29
+ print(f"Added to sys.path: {project_root_for_lollms_client}")
30
+
31
+
32
+ # --- Ensure pipmaster is available (core LoLLMs dependency) ---
33
+ try:
34
+ import pipmaster as pm
35
+ except ImportError:
36
+ print("ERROR: pipmaster is not installed or not in PYTHONPATH.")
37
+ sys.exit(1)
38
+
39
+ # --- Import LollmsClient and supporting components ---
40
+ try:
41
+
42
+ from lollms_client.lollms_llm_binding import LollmsLLMBinding # Base for LLM
43
+ from ascii_colors import ASCIIColors, trace_exception
44
+ from lollms_client.lollms_types import MSG_TYPE # Assuming MSG_TYPE is here
45
+ except ImportError as e:
46
+ print(f"ERROR: Could not import LollmsClient components: {e}")
47
+ print("Ensure 'lollms_client' package structure is correct and accessible via PYTHONPATH.")
48
+ print(f"Current sys.path: {sys.path}")
49
+ trace_exception(e)
50
+ sys.exit(1)
51
+
52
+
53
+ # --- Dummy Server Scripts using FastMCP (as per previous successful iteration) ---
54
+ TIME_SERVER_PY = """
55
+ import asyncio
56
+ from datetime import datetime
57
+ from mcp.server.fastmcp import FastMCP
58
+
59
+ mcp_server = FastMCP("TimeMCP", description="A server that provides the current time.", host="localhost",
60
+ port=9624,
61
+ log_level="DEBUG")
62
+
63
+ @mcp_server.tool(description="Returns the current server time and echoes received parameters.")
64
+ def get_current_time(user_id: str = "unknown_user") -> dict:
65
+ return {"time": datetime.now().isoformat(), "params_received": {"user_id": user_id}, "server_name": "TimeServer"}
66
+
67
+ if __name__ == "__main__":
68
+ mcp_server.run(transport="streamable-http")
69
+ """
70
+
71
+ CALCULATOR_SERVER_PY = """
72
+ import asyncio
73
+ from typing import List, Union
74
+ from mcp.server.fastmcp import FastMCP
75
+
76
+ mcp_server = FastMCP("CalculatorMCP", description="A server that performs addition.", host="localhost",
77
+ port=9625,
78
+ log_level="DEBUG")
79
+
80
+ @mcp_server.tool(description="Adds a list of numbers provided in the 'numbers' parameter.")
81
+ def add_numbers(numbers: List[Union[int, float]]) -> dict:
82
+ if not isinstance(numbers, list) or not all(isinstance(x, (int, float)) for x in numbers):
83
+ return {"error": "'numbers' must be a list of numbers."}
84
+ return {"sum": sum(numbers), "server_name": "CalculatorServer"}
85
+
86
+ if __name__ == "__main__":
87
+ mcp_server.run(transport="streamable-http")
88
+ """
89
+
90
+
91
+ def main():
92
+ ASCIIColors.red("--- Example: Using LollmsClient with StandardMCPBinding ---")
93
+
94
+ # --- 1. Setup Temporary Directory for Dummy MCP Servers ---
95
+ example_base_dir = Path(__file__).parent / "temp_mcp_example_servers"
96
+ if example_base_dir.exists():
97
+ shutil.rmtree(example_base_dir)
98
+ example_base_dir.mkdir(exist_ok=True)
99
+
100
+ time_server_script_path = example_base_dir / "time_server.py"
101
+ with open(time_server_script_path, "w") as f: f.write(TIME_SERVER_PY)
102
+
103
+ calculator_server_script_path = example_base_dir / "calculator_server.py"
104
+ with open(calculator_server_script_path, "w") as f: f.write(CALCULATOR_SERVER_PY)
105
+
106
+ subprocess.Popen(
107
+ [sys.executable, str(time_server_script_path.resolve())],
108
+ stdin=subprocess.DEVNULL,
109
+ stdout=subprocess.DEVNULL,
110
+ stderr=subprocess.DEVNULL,
111
+ start_new_session=True
112
+ )
113
+
114
+ subprocess.Popen(
115
+ [sys.executable, str(calculator_server_script_path.resolve())],
116
+ stdin=subprocess.DEVNULL,
117
+ stdout=subprocess.DEVNULL,
118
+ stderr=subprocess.DEVNULL,
119
+ start_new_session=True
120
+ )
121
+ # MCP Binding Configuration (for RemoteMCPBinding with multiple servers)
122
+ mcp_config = {
123
+ "servers_infos":{
124
+ "time_machine":{
125
+ "server_url": "http://localhost:9624/mcp",
126
+ },
127
+
128
+ "calc_unit":{
129
+ "server_url": "http://localhost:9625/mcp",
130
+ },
131
+ }
132
+ }
133
+ ASCIIColors.magenta("\n1. Initializing LollmsClient...")
134
+ try:
135
+ client = LollmsClient(
136
+ binding_name="ollama", # Use the dummy LLM binding
137
+ model_name="mistral-nemo:latest",
138
+ mcp_binding_name="remote_mcp",
139
+ mcp_binding_config=mcp_config,
140
+
141
+ )
142
+ except Exception as e:
143
+ ASCIIColors.error(f"Failed to initialize LollmsClient: {e}")
144
+ trace_exception(e)
145
+ shutil.rmtree(example_base_dir)
146
+ sys.exit(1)
147
+
148
+ if not client.binding:
149
+ ASCIIColors.error("LollmsClient's LLM binding (dummy_llm) failed to load.")
150
+ shutil.rmtree(example_base_dir)
151
+ sys.exit(1)
152
+ if not client.mcp:
153
+ ASCIIColors.error("LollmsClient's MCP binding (standard_mcp) failed to load.")
154
+ client.close() # Close LLM binding if it loaded
155
+ shutil.rmtree(example_base_dir)
156
+ sys.exit(1)
157
+
158
+ ASCIIColors.green("LollmsClient initialized successfully with DummyLLM and StandardMCP bindings.")
159
+
160
+ # --- 3. Define a streaming callback for generate_with_mcp ---
161
+ def mcp_streaming_callback(chunk: str, msg_type: MSG_TYPE, metadata: dict = None, history: list = None) -> bool:
162
+ if metadata:
163
+ type_info = metadata.get('type', 'unknown_type')
164
+ if msg_type == MSG_TYPE.MSG_TYPE_STEP_START:
165
+ ASCIIColors.cyan(f"MCP Step Start ({type_info}): {chunk}")
166
+ elif msg_type == MSG_TYPE.MSG_TYPE_STEP_END:
167
+ ASCIIColors.cyan(f"MCP Step End ({type_info}): {chunk}")
168
+ elif msg_type == MSG_TYPE.MSG_TYPE_INFO:
169
+ ASCIIColors.yellow(f"MCP Info ({type_info}): {chunk}")
170
+ elif msg_type == MSG_TYPE.MSG_TYPE_CHUNK: # Part of final answer typically
171
+ ASCIIColors.green(chunk, end="") # type: ignore
172
+ else: # FULL, default, etc.
173
+ ASCIIColors.green(f"MCP Output ({str(msg_type)}, {type_info}): {chunk}")
174
+ else:
175
+ if msg_type == MSG_TYPE.MSG_TYPE_CHUNK:
176
+ ASCIIColors.green(chunk, end="") # type: ignore
177
+ else:
178
+ ASCIIColors.green(f"MCP Output ({str(msg_type)}): {chunk}")
179
+ sys.stdout.flush()
180
+ return True # Continue streaming
181
+
182
+ # --- 4. Use generate_with_mcp ---
183
+ ASCIIColors.magenta("\n2. Calling generate_with_mcp to get current time...")
184
+ time_prompt = "Hey assistant, what time is it right now?"
185
+ time_response = client.generate_with_mcp(
186
+ prompt=time_prompt,
187
+ streaming_callback=mcp_streaming_callback,
188
+ interactive_tool_execution=False # Set to True to test interactive mode
189
+ )
190
+ print() # Newline after streaming
191
+ ASCIIColors.blue(f"Final response for time prompt: {json.dumps(time_response, indent=2)}")
192
+
193
+ assert time_response.get("error") is None, f"Time prompt resulted in an error: {time_response.get('error')}"
194
+ assert time_response.get("final_answer"), "Time prompt did not produce a final answer."
195
+ assert len(time_response.get("tool_calls", [])) > 0, "Time prompt should have called a tool."
196
+ assert time_response["tool_calls"][0]["name"] == "time_machine::get_current_time", "Incorrect tool called for time."
197
+ assert "time" in time_response["tool_calls"][0].get("result", {}).get("output", {}), "Time tool result missing time."
198
+
199
+
200
+ ASCIIColors.magenta("\n3. Calling generate_with_mcp for calculation...")
201
+ calc_prompt = "Can you please calculate the sum of 50, 25, and 7.5 for me?"
202
+ calc_response = client.generate_with_mcp(
203
+ prompt=calc_prompt,
204
+ streaming_callback=mcp_streaming_callback
205
+ )
206
+ print() # Newline
207
+ ASCIIColors.blue(f"Final response for calc prompt: {json.dumps(calc_response, indent=2)}")
208
+
209
+ assert calc_response.get("error") is None, f"Calc prompt resulted in an error: {calc_response.get('error')}"
210
+ assert calc_response.get("final_answer"), "Calc prompt did not produce a final answer."
211
+ assert len(calc_response.get("tool_calls", [])) > 0, "Calc prompt should have called a tool."
212
+ assert calc_response["tool_calls"][0]["name"] == "calc_unit::add_numbers", "Incorrect tool called for calculation."
213
+ # The dummy LLM uses hardcoded params [1,2,3] for calc, so result will be 6.
214
+ # A real LLM would extract 50, 25, 7.5.
215
+ # For this dummy test, we check against the dummy's behavior.
216
+ assert calc_response["tool_calls"][0].get("result", {}).get("output", {}).get("sum") == 82.5, "Calculator tool result mismatch for dummy params."
217
+
218
+
219
+ # --- 5. Cleanup ---
220
+ ASCIIColors.info("Cleaning up temporary server scripts and dummy binding directory...")
221
+ shutil.rmtree(example_base_dir, ignore_errors=True)
222
+
223
+ ASCIIColors.red("\n--- LollmsClient with StandardMCPBinding Example Finished Successfully! ---")
224
+
225
+ if __name__ == "__main__":
226
+ main()
lollms_client/__init__.py CHANGED
@@ -7,7 +7,7 @@ from lollms_client.lollms_utilities import PromptReshaper # Keep general utiliti
7
7
  from lollms_client.lollms_mcp_binding import LollmsMCPBinding, LollmsMCPBindingManager
8
8
 
9
9
 
10
- __version__ = "0.20.4" # Updated version
10
+ __version__ = "0.20.6" # Updated version
11
11
 
12
12
  # Optionally, you could define __all__ if you want to be explicit about exports
13
13
  __all__ = [
@@ -19,4 +19,4 @@ __all__ = [
19
19
  "PromptReshaper",
20
20
  "LollmsMCPBinding", # Export LollmsMCPBinding ABC
21
21
  "LollmsMCPBindingManager", # Export LollmsMCPBindingManager
22
- ]
22
+ ]
@@ -54,10 +54,13 @@ class LollmsDiscussion:
54
54
  content: str,
55
55
  metadata: Dict = {},
56
56
  parent_id: Optional[str] = None,
57
- images: Optional[List[Dict[str, str]]] = None
57
+ images: Optional[List[Dict[str, str]]] = None,
58
+ override_id: Optional[str] = None
58
59
  ) -> str:
59
60
  if parent_id is None:
60
61
  parent_id = self.active_branch_id
62
+ if parent_id is None:
63
+ parent_id = "main"
61
64
 
62
65
  message = LollmsMessage(
63
66
  sender=sender,
@@ -66,6 +69,8 @@ class LollmsDiscussion:
66
69
  metadata=str(metadata),
67
70
  images=images or []
68
71
  )
72
+ if override_id:
73
+ message.id = override_id
69
74
 
70
75
  self.messages.append(message)
71
76
  self.message_index[message.id] = message
@@ -491,4 +496,4 @@ if __name__ == "__main__":
491
496
  final_message = new_discussion.message_index[new_discussion.active_branch_id]
492
497
  assert len(final_message.images) == 2
493
498
  assert final_message.images[1]['type'] == 'base64'
494
- print("\n✅ Verification successful: Images were loaded correctly from the file.")
499
+ print("\n✅ Verification successful: Images were loaded correctly from the file.")
@@ -1,68 +1,95 @@
1
- # Conceptual: lollms_client/mcp_bindings/remote_mcp/__init__.py
2
-
3
1
  import asyncio
4
2
  from contextlib import AsyncExitStack
5
3
  from typing import Optional, List, Dict, Any, Tuple
6
4
  from lollms_client.lollms_mcp_binding import LollmsMCPBinding
7
5
  from ascii_colors import ASCIIColors, trace_exception
8
6
  import threading
7
+ import json
8
+
9
9
  try:
10
10
  from mcp import ClientSession, types
11
- # Import the specific network client from MCP SDK
12
11
  from mcp.client.streamable_http import streamablehttp_client
13
- # If supporting OAuth, you'd import auth components:
14
- # from mcp.client.auth import OAuthClientProvider, TokenStorage
15
- # from mcp.shared.auth import OAuthClientMetadata, OAuthToken
16
12
  MCP_LIBRARY_AVAILABLE = True
17
13
  except ImportError:
18
- # ... (error handling as in StandardMCPBinding) ...
19
14
  MCP_LIBRARY_AVAILABLE = False
20
- ClientSession = None # etc.
15
+ ClientSession = None
21
16
  streamablehttp_client = None
22
17
 
23
18
 
24
19
  BindingName = "RemoteMCPBinding"
25
- # No TOOL_NAME_SEPARATOR needed if connecting to one remote server per instance,
26
- # or if server aliases are handled differently (e.g. part of URL or config)
27
20
  TOOL_NAME_SEPARATOR = "::"
28
21
 
29
22
  class RemoteMCPBinding(LollmsMCPBinding):
23
+ """
24
+ This binding allows the connection to one or more remote MCP servers.
25
+ Tools from all connected servers are aggregated and prefixed with the server's alias.
26
+ """
30
27
  def __init__(self,
31
- server_url: str, # e.g., "http://localhost:8000/mcp"
32
- alias: str = "remote_server", # An alias for this connection
33
- auth_config: Optional[Dict[str, Any]] = None, # For API keys, OAuth, etc.
28
+ servers_infos: Dict[str, Dict[str, Any]],
34
29
  **other_config_params: Any):
30
+ """
31
+ Initializes the binding to connect to multiple MCP servers.
32
+
33
+ Args:
34
+ servers_infos (Dict[str, Dict[str, Any]]): A dictionary where each key is a unique
35
+ alias for a server, and the value is another dictionary containing connection
36
+ details for that server.
37
+ Example:
38
+ {
39
+ "main_server": {"server_url": "http://localhost:8787", "auth_config": {}},
40
+ "experimental_server": {"server_url": "http://test.server:9000"}
41
+ }
42
+ **other_config_params (Any): Additional configuration parameters.
43
+ """
35
44
  super().__init__(binding_name="remote_mcp")
36
45
 
37
46
  if not MCP_LIBRARY_AVAILABLE:
38
- ASCIIColors.error(f"{self.binding_name}: MCP library not available.")
47
+ ASCIIColors.error(f"{self.binding_name}: MCP library not available. This binding will be disabled.")
39
48
  return
40
49
 
41
- if not server_url:
42
- ASCIIColors.error(f"{self.binding_name}: server_url is required.")
43
- # Or raise ValueError
50
+ if not servers_infos or not isinstance(servers_infos, dict):
51
+ ASCIIColors.error(f"{self.binding_name}: `servers_infos` dictionary is required and cannot be empty.")
44
52
  return
45
53
 
46
- self.server_url = server_url
47
- self.alias = alias # Could be used to prefix tool names if managing multiple remotes
48
- self.auth_config = auth_config or {}
54
+ ### NEW: Store the overall configuration
49
55
  self.config = {
50
- "server_url": server_url,
51
- "alias": alias,
52
- "auth_config": self.auth_config
56
+ "servers_infos": servers_infos,
57
+ **other_config_params
53
58
  }
54
- self.config.update(other_config_params)
55
59
 
56
- self._mcp_session: Optional[ClientSession] = None
57
- self._exit_stack: Optional[AsyncExitStack] = None
60
+ ### NEW: State management for multiple servers.
61
+ # The key is the server alias. The value is a dictionary holding the state for that server.
62
+ self.servers: Dict[str, Dict[str, Any]] = {}
63
+ for alias, info in servers_infos.items():
64
+ if "server_url" not in info:
65
+ ASCIIColors.warning(f"{self.binding_name}: Skipping server '{alias}' due to missing 'server_url'.")
66
+ continue
67
+
68
+ self.servers[alias] = {
69
+ "url": info["server_url"],
70
+ "auth_config": info.get("auth_config", {}),
71
+ "session": None, # Will hold the ClientSession
72
+ "exit_stack": None, # Will hold the AsyncExitStack
73
+ "initialized": False,
74
+ "initializing_lock": threading.Lock() # Prevents race conditions on initialization
75
+ }
76
+
58
77
  self._discovered_tools_cache: List[Dict[str, Any]] = []
59
- self._is_initialized = False
78
+
79
+ ### MODIFIED: These are now shared across all connections
60
80
  self._loop: Optional[asyncio.AbstractEventLoop] = None
61
81
  self._thread: Optional[threading.Thread] = None
82
+ self._loop_started_event = threading.Event()
62
83
 
63
- self._start_event_loop_thread() # Similar to StandardMCPBinding
84
+ if self.servers:
85
+ self._start_event_loop_thread()
86
+ else:
87
+ ASCIIColors.warning(f"{self.binding_name}: No valid servers configured.")
64
88
 
65
- def _start_event_loop_thread(self): # Simplified from StandardMCPBinding
89
+ # _start_event_loop_thread, _run_loop_forever, _wait_for_loop, _run_async
90
+ # are utility methods for the shared event loop and do not need to be changed.
91
+ # They manage the async infrastructure for the entire binding instance.
92
+ def _start_event_loop_thread(self):
66
93
  if self._loop and self._loop.is_running(): return
67
94
  self._loop = asyncio.new_event_loop()
68
95
  self._thread = threading.Thread(target=self._run_loop_forever, daemon=True)
@@ -71,83 +98,115 @@ class RemoteMCPBinding(LollmsMCPBinding):
71
98
  def _run_loop_forever(self):
72
99
  if not self._loop: return
73
100
  asyncio.set_event_loop(self._loop)
74
- try: self._loop.run_forever()
101
+ try:
102
+ self._loop_started_event.set()
103
+ self._loop.run_forever()
75
104
  finally:
76
- # ... (loop cleanup as in StandardMCPBinding) ...
77
105
  if not self._loop.is_closed(): self._loop.close()
78
106
 
79
- def _run_async(self, coro, timeout=None): # Simplified
80
- if not self._loop or not self._loop.is_running(): raise RuntimeError("Event loop not running.")
107
+ def _wait_for_loop(self, timeout=5.0):
108
+ if not self._loop_started_event.wait(timeout=timeout):
109
+ raise RuntimeError(f"{self.binding_name}: Event loop thread failed to start in time.")
110
+ if not self._loop or not self._loop.is_running():
111
+ raise RuntimeError(f"{self.binding_name}: Event loop is not running after start signal.")
112
+
113
+ def _run_async(self, coro, timeout=None):
114
+ if not self._loop or not self._loop.is_running():
115
+ raise RuntimeError("Event loop not running. This should have been caught earlier.")
81
116
  future = asyncio.run_coroutine_threadsafe(coro, self._loop)
82
117
  return future.result(timeout)
83
118
 
84
- async def _initialize_connection_async(self) -> bool:
85
- if self._is_initialized: return True
86
- ASCIIColors.info(f"{self.binding_name}: Initializing connection to {self.server_url}...")
87
- try:
88
- self._exit_stack = AsyncExitStack()
89
-
90
- # --- Authentication Setup (Conceptual) ---
91
- # oauth_provider = None
92
- # if self.auth_config.get("type") == "oauth":
93
- # # oauth_provider = OAuthClientProvider(...) # Setup based on auth_config
94
- # pass
95
- # http_headers = {}
96
- # if self.auth_config.get("type") == "api_key":
97
- # key = self.auth_config.get("key")
98
- # header_name = self.auth_config.get("header_name", "X-API-Key")
99
- # if key: http_headers[header_name] = key
119
+ ### MODIFIED: Now operates on a specific server identified by alias
120
+ async def _initialize_connection_async(self, alias: str) -> bool:
121
+ server_info = self.servers[alias]
122
+ if server_info["initialized"]:
123
+ return True
100
124
 
101
- # Use streamablehttp_client from MCP SDK
102
- # The `auth` parameter of streamablehttp_client takes an OAuthClientProvider
103
- # For simple API key headers, you might need to use `httpx` directly
104
- # or see if streamablehttp_client allows passing custom headers.
105
- # The MCP client example for streamable HTTP doesn't show custom headers directly,
106
- # it focuses on OAuth.
107
- # If `streamablehttp_client` takes `**kwargs` that are passed to `httpx.AsyncClient`,
108
- # then `headers=http_headers` might work.
125
+ server_url = server_info["url"]
126
+ ASCIIColors.info(f"{self.binding_name}: Initializing connection to '{alias}' ({server_url})...")
127
+ try:
128
+ exit_stack = AsyncExitStack()
109
129
 
110
- # Assuming streamablehttp_client can take headers if needed, or auth provider
111
- # For now, let's assume no auth for simplicity or that it's handled by underlying httpx if passed via kwargs
112
- client_streams = await self._exit_stack.enter_async_context(
113
- streamablehttp_client(self.server_url) # Add auth=oauth_provider or headers=http_headers if supported
130
+ client_streams = await exit_stack.enter_async_context(
131
+ streamablehttp_client(server_url)
114
132
  )
115
- read_stream, write_stream, _http_client_instance = client_streams # http_client_instance might be useful
133
+ read_stream, write_stream, _ = client_streams
116
134
 
117
- self._mcp_session = await self._exit_stack.enter_async_context(
135
+ session = await exit_stack.enter_async_context(
118
136
  ClientSession(read_stream, write_stream)
119
137
  )
120
- await self._mcp_session.initialize()
121
- self._is_initialized = True
122
- ASCIIColors.green(f"{self.binding_name}: Connected to {self.server_url}")
123
- await self._refresh_tools_cache_async()
138
+ await session.initialize()
139
+
140
+ # Update the state for this specific server
141
+ server_info["session"] = session
142
+ server_info["exit_stack"] = exit_stack
143
+ server_info["initialized"] = True
144
+
145
+ ASCIIColors.green(f"{self.binding_name}: Connected to '{alias}' ({server_url})")
124
146
  return True
125
147
  except Exception as e:
126
148
  trace_exception(e)
127
- ASCIIColors.error(f"{self.binding_name}: Failed to connect to {self.server_url}: {e}")
128
- if self._exit_stack: await self._exit_stack.aclose() # Cleanup on failure
129
- self._exit_stack = None
130
- self._mcp_session = None
131
- self._is_initialized = False
149
+ ASCIIColors.error(f"{self.binding_name}: Failed to connect to '{alias}' ({server_url}): {e}")
150
+ if 'exit_stack' in locals() and exit_stack:
151
+ await exit_stack.aclose()
152
+
153
+ # Reset state for this server on failure
154
+ server_info["session"] = None
155
+ server_info["exit_stack"] = None
156
+ server_info["initialized"] = False
132
157
  return False
133
158
 
134
- def _ensure_initialized_sync(self, timeout=30.0):
135
- if not self._is_initialized:
136
- success = self._run_async(self._initialize_connection_async(), timeout=timeout)
137
- if not success: raise ConnectionError(f"Failed to initialize remote MCP connection to {self.server_url}")
138
- if not self._mcp_session: # Double check
139
- raise ConnectionError(f"MCP Session not valid after init attempt for {self.server_url}")
159
+ ### MODIFIED: Ensures a specific server is initialized
160
+ def _ensure_initialized_sync(self, alias: str, timeout=30.0):
161
+ self._wait_for_loop()
162
+
163
+ server_info = self.servers.get(alias)
164
+ if not server_info:
165
+ raise ValueError(f"Unknown server alias: '{alias}'")
140
166
 
167
+ # Use a lock to prevent multiple threads trying to initialize the same server
168
+ with server_info["initializing_lock"]:
169
+ if not server_info["initialized"]:
170
+ success = self._run_async(self._initialize_connection_async(alias), timeout=timeout)
171
+ if not success:
172
+ raise ConnectionError(f"Failed to initialize remote MCP connection to '{alias}' ({server_info['url']})")
173
+
174
+ if not server_info.get("session"):
175
+ raise ConnectionError(f"MCP Session not valid after init attempt for '{alias}' ({server_info['url']})")
141
176
 
142
- async def _refresh_tools_cache_async(self):
143
- if not self._is_initialized or not self._mcp_session: return
144
- ASCIIColors.info(f"{self.binding_name}: Refreshing tools from {self.server_url}...")
177
+ ### MODIFIED: Refreshes tools from ALL connected servers and aggregates them
178
+ async def _refresh_all_tools_cache_async(self):
179
+ ASCIIColors.info(f"{self.binding_name}: Refreshing tools from all servers...")
180
+ all_tools = []
181
+ # Create a list of tasks to run concurrently
182
+ refresh_tasks = [
183
+ self._fetch_tools_from_server_async(alias) for alias in self.servers.keys()
184
+ ]
185
+
186
+ # Gather results from all tasks
187
+ results = await asyncio.gather(*refresh_tasks, return_exceptions=True)
188
+
189
+ for result in results:
190
+ if isinstance(result, Exception):
191
+ # Error already logged inside the fetch function
192
+ continue
193
+ if result:
194
+ all_tools.extend(result)
195
+
196
+ self._discovered_tools_cache = all_tools
197
+ ASCIIColors.green(f"{self.binding_name}: Tool refresh complete. Found {len(all_tools)} tools across all servers.")
198
+
199
+ ### NEW: Helper async function to fetch tools from a single server
200
+ async def _fetch_tools_from_server_async(self, alias: str) -> List[Dict[str, Any]]:
201
+ server_info = self.servers[alias]
202
+ if not server_info["initialized"] or not server_info["session"]:
203
+ ASCIIColors.debug(f"{self.binding_name}: Skipping tool refresh for non-initialized server '{alias}'.")
204
+ return []
205
+
145
206
  try:
146
- list_tools_result = await self._mcp_session.list_tools()
147
- current_tools = []
148
- # ... (tool parsing logic similar to StandardMCPBinding, but no server alias prefix needed if one server per binding instance)
207
+ list_tools_result = await server_info["session"].list_tools()
208
+ server_tools = []
149
209
  for tool_obj in list_tools_result.tools:
150
- # ...
151
210
  input_schema_dict = {}
152
211
  tool_input_schema = getattr(tool_obj, 'inputSchema', getattr(tool_obj, 'input_schema', None))
153
212
  if tool_input_schema:
@@ -156,86 +215,128 @@ class RemoteMCPBinding(LollmsMCPBinding):
156
215
  elif isinstance(tool_input_schema, dict):
157
216
  input_schema_dict = tool_input_schema
158
217
 
159
- tool_name_for_client = f"{self.alias}{TOOL_NAME_SEPARATOR}{tool_obj.name}" if TOOL_NAME_SEPARATOR else tool_obj.name
218
+ tool_name_for_client = f"{alias}{TOOL_NAME_SEPARATOR}{tool_obj.name}"
160
219
 
161
- current_tools.append({
162
- "name": tool_name_for_client, # Use self.alias to prefix
220
+ server_tools.append({
221
+ "name": tool_name_for_client,
163
222
  "description": tool_obj.description or "",
164
223
  "input_schema": input_schema_dict
165
224
  })
166
- self._discovered_tools_cache = current_tools
167
- ASCIIColors.green(f"{self.binding_name}: Tools refreshed for {self.server_url}. Found {len(current_tools)} tools.")
225
+ ASCIIColors.info(f"{self.binding_name}: Found {len(server_tools)} tools on server '{alias}'.")
226
+ return server_tools
168
227
  except Exception as e:
169
228
  trace_exception(e)
170
- ASCIIColors.error(f"{self.binding_name}: Error refreshing tools from {self.server_url}: {e}")
229
+ ASCIIColors.error(f"{self.binding_name}: Error refreshing tools from '{alias}': {e}")
230
+ return []
231
+
171
232
 
172
- def discover_tools(self, force_refresh: bool = False, timeout_per_server: float = 10.0, **kwargs) -> List[Dict[str, Any]]:
173
- # This binding instance connects to ONE server, so timeout_per_server is just 'timeout'
233
+ ### MODIFIED: Discovers tools from all configured servers
234
+ def discover_tools(self, force_refresh: bool = False, timeout_per_server: float = 30.0, **kwargs) -> List[Dict[str, Any]]:
235
+ if not self.servers:
236
+ return []
237
+
238
+ # Initialize all servers that are not yet initialized.
239
+ for alias in self.servers.keys():
240
+ try:
241
+ # _ensure_initialized_sync is internally locked and idempotent
242
+ self._ensure_initialized_sync(alias, timeout=timeout_per_server)
243
+ except Exception as e:
244
+ # One server failing to connect shouldn't stop discovery on others.
245
+ ASCIIColors.warning(f"{self.binding_name}: Could not ensure connection to '{alias}' for discovery: {e}")
246
+
174
247
  try:
175
- self._ensure_initialized_sync(timeout=timeout_per_server)
176
248
  if force_refresh or not self._discovered_tools_cache:
177
- self._run_async(self._refresh_tools_cache_async(), timeout=timeout_per_server)
249
+ # The timeout for refreshing all tools should be longer
250
+ self._run_async(self._refresh_all_tools_cache_async(), timeout=timeout_per_server * len(self.servers))
178
251
  return self._discovered_tools_cache
179
252
  except Exception as e:
180
- ASCIIColors.error(f"{self.binding_name}: Problem during tool discovery for {self.server_url}: {e}")
253
+ trace_exception(e)
254
+ ASCIIColors.error(f"{self.binding_name}: Problem during tool discovery: {e}")
181
255
  return []
182
256
 
183
- async def _execute_tool_async(self, actual_tool_name: str, params: Dict[str, Any]) -> Dict[str, Any]:
184
- if not self._is_initialized or not self._mcp_session:
185
- return {"error": f"Not connected to {self.server_url}", "status_code": 503}
257
+ ### MODIFIED: Now operates on a specific server identified by alias
258
+ async def _execute_tool_async(self, alias: str, actual_tool_name: str, params: Dict[str, Any]) -> Dict[str, Any]:
259
+ server_info = self.servers[alias]
260
+ server_url = server_info["url"]
186
261
 
187
- ASCIIColors.info(f"{self.binding_name}: Executing remote tool '{actual_tool_name}' on {self.server_url} with params: {json.dumps(params)}")
262
+ if not server_info["initialized"] or not server_info["session"]:
263
+ return {"error": f"Not connected to server '{alias}' ({server_url})", "status_code": 503}
264
+
265
+ ASCIIColors.info(f"{self.binding_name}: Executing remote tool '{actual_tool_name}' on '{alias}' ({server_url}) with params: {json.dumps(params)}")
188
266
  try:
189
- mcp_call_result = await self._mcp_session.call_tool(name=actual_tool_name, arguments=params)
190
- # ... (result parsing as in StandardMCPBinding) ...
267
+ mcp_call_result = await server_info["session"].call_tool(name=actual_tool_name, arguments=params)
191
268
  output_parts = [p.text for p in mcp_call_result.content if isinstance(p, types.TextContent) and p.text is not None] if mcp_call_result.content else []
192
269
  if not output_parts: return {"output": {"message": "Tool executed but returned no textual content."}, "status_code": 200}
270
+
193
271
  combined_output_str = "\n".join(output_parts)
194
- try: return {"output": json.loads(combined_output_str), "status_code": 200}
195
- except json.JSONDecodeError: return {"output": combined_output_str, "status_code": 200}
272
+ try:
273
+ return {"output": json.loads(combined_output_str), "status_code": 200}
274
+ except json.JSONDecodeError:
275
+ return {"output": combined_output_str, "status_code": 200}
196
276
  except Exception as e:
197
277
  trace_exception(e)
198
- return {"error": f"Error executing remote tool '{actual_tool_name}': {str(e)}", "status_code": 500}
199
-
278
+ return {"error": f"Error executing remote tool '{actual_tool_name}' on '{alias}': {str(e)}", "status_code": 500}
200
279
 
280
+ ### MODIFIED: Parses alias from tool name and routes the call
201
281
  def execute_tool(self, tool_name_with_alias: str, params: Dict[str, Any], **kwargs) -> Dict[str, Any]:
202
282
  timeout = float(kwargs.get('timeout', 60.0))
203
283
 
204
- # If using alias prefixing (self.alias + TOOL_NAME_SEPARATOR + actual_name)
205
- expected_prefix = f"{self.alias}{TOOL_NAME_SEPARATOR}"
206
- if TOOL_NAME_SEPARATOR and tool_name_with_alias.startswith(expected_prefix):
207
- actual_tool_name = tool_name_with_alias[len(expected_prefix):]
208
- elif not TOOL_NAME_SEPARATOR and tool_name_with_alias: # No prefixing, tool_name is actual_tool_name
209
- actual_tool_name = tool_name_with_alias
210
- else:
211
- return {"error": f"Tool name '{tool_name_with_alias}' does not match expected alias '{self.alias}'.", "status_code": 400}
284
+ if TOOL_NAME_SEPARATOR not in tool_name_with_alias:
285
+ return {"error": f"Invalid tool name format. Expected 'alias{TOOL_NAME_SEPARATOR}tool_name', but got '{tool_name_with_alias}'.", "status_code": 400}
286
+
287
+ alias, actual_tool_name = tool_name_with_alias.split(TOOL_NAME_SEPARATOR, 1)
288
+
289
+ if alias not in self.servers:
290
+ return {"error": f"Tool name '{tool_name_with_alias}' has an unknown server alias '{alias}'.", "status_code": 400}
212
291
 
213
292
  try:
214
- self._ensure_initialized_sync(timeout=min(timeout, 30.0))
215
- return self._run_async(self._execute_tool_async(actual_tool_name, params), timeout=timeout)
216
- # ... (error handling as in StandardMCPBinding) ...
217
- except ConnectionError as e: return {"error": f"{self.binding_name}: Connection issue for '{self.server_url}': {e}", "status_code": 503}
218
- except TimeoutError: return {"error": f"{self.binding_name}: Remote tool '{actual_tool_name}' on '{self.server_url}' timed out.", "status_code": 504}
293
+ # Ensure this specific server is connected before executing
294
+ self._ensure_initialized_sync(alias, timeout=min(timeout, 30.0))
295
+ return self._run_async(self._execute_tool_async(alias, actual_tool_name, params), timeout=timeout)
296
+ except (ConnectionError, RuntimeError) as e:
297
+ return {"error": f"{self.binding_name}: Connection issue for server '{alias}': {e}", "status_code": 503}
298
+ except TimeoutError:
299
+ return {"error": f"{self.binding_name}: Remote tool '{actual_tool_name}' on '{alias}' timed out.", "status_code": 504}
219
300
  except Exception as e:
220
301
  trace_exception(e)
221
- return {"error": f"{self.binding_name}: Failed to run remote MCP tool '{actual_tool_name}': {e}", "status_code": 500}
302
+ return {"error": f"{self.binding_name}: Failed to run remote MCP tool '{actual_tool_name}' on '{alias}': {e}", "status_code": 500}
222
303
 
304
+ ### MODIFIED: Closes all connections
223
305
  def close(self):
224
- ASCIIColors.info(f"{self.binding_name}: Closing connection to {self.server_url}...")
225
- if self._exit_stack:
306
+ ASCIIColors.info(f"{self.binding_name}: Closing all remote connections...")
307
+
308
+ async def _close_all_connections():
309
+ close_tasks = []
310
+ for alias, server_info in self.servers.items():
311
+ if server_info.get("exit_stack"):
312
+ ASCIIColors.info(f"{self.binding_name}: Closing connection to '{alias}'...")
313
+ close_tasks.append(server_info["exit_stack"].aclose())
314
+
315
+ if close_tasks:
316
+ await asyncio.gather(*close_tasks, return_exceptions=True)
317
+
318
+ # Check if loop is running before trying to schedule work on it
319
+ if self._loop and self._loop.is_running():
226
320
  try:
227
- # The anyio task error might also occur here if not careful
228
- self._run_async(self._exit_stack.aclose(), timeout=10.0)
321
+ self._run_async(_close_all_connections(), timeout=10.0)
229
322
  except Exception as e:
230
- ASCIIColors.error(f"{self.binding_name}: Error during async close for {self.server_url}: {e}")
231
- self._exit_stack = None
232
- self._mcp_session = None
233
- self._is_initialized = False
323
+ ASCIIColors.error(f"{self.binding_name}: Error during async close: {e}")
324
+
325
+ # Reset all server states
326
+ for alias in self.servers:
327
+ self.servers[alias].update({
328
+ "exit_stack": None,
329
+ "session": None,
330
+ "initialized": False
331
+ })
234
332
 
235
- # Stop event loop thread
236
- if self._loop and self._loop.is_running(): self._loop.call_soon_threadsafe(self._loop.stop)
237
- if self._thread and self._thread.is_alive(): self._thread.join(timeout=5.0)
333
+ if self._loop and self._loop.is_running():
334
+ self._loop.call_soon_threadsafe(self._loop.stop)
335
+
336
+ if self._thread and self._thread.is_alive():
337
+ self._thread.join(timeout=5.0)
338
+
238
339
  ASCIIColors.info(f"{self.binding_name}: Remote connection binding closed.")
239
340
 
240
- def get_binding_config(self) -> Dict[str, Any]: # LollmsMCPBinding might expect this
341
+ def get_binding_config(self) -> Dict[str, Any]:
241
342
  return self.config
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lollms_client
3
- Version: 0.20.4
3
+ Version: 0.20.6
4
4
  Summary: A client library for LoLLMs generate endpoint
5
5
  Author-email: ParisNeo <parisneoai@gmail.com>
6
6
  License: Apache Software License
@@ -6,6 +6,7 @@ examples/gradio_chat_app.py,sha256=ZZ_D1U0wvvwE9THmAPXUvNKkFG2gi7tQq1f2pQx_2ug,1
6
6
  examples/internet_search_with_rag.py,sha256=ioTb_WI2M6kFeh1Dg-EGcKjccphnCsIGD_e9PZgZshw,12314
7
7
  examples/local_mcp.py,sha256=w40dgayvHYe01yvekEE0LjcbkpwKjWwJ-9v4_wGYsUk,9113
8
8
  examples/openai_mcp.py,sha256=7IEnPGPXZgYZyiES_VaUbQ6viQjenpcUxGiHE-pGeFY,11060
9
+ examples/run_remote_mcp_example copy.py,sha256=pGT8A5iXK9oHtjGNEUCm8fnj9DQ37gcznjLYqAEI20o,10075
9
10
  examples/run_standard_mcp_example.py,sha256=GSZpaACPf3mDPsjA8esBQVUsIi7owI39ca5avsmvCxA,9419
10
11
  examples/simple_text_gen_test.py,sha256=RoX9ZKJjGMujeep60wh5WT_GoBn0O9YKJY6WOy-ZmOc,8710
11
12
  examples/simple_text_gen_with_image_test.py,sha256=rR1O5Prcb52UHtJ3c6bv7VuTd1cvbkr5aNZU-v-Rs3Y,9263
@@ -24,10 +25,10 @@ examples/personality_test/chat_test.py,sha256=o2jlpoddFc-T592iqAiA29xk3x27KsdK5D
24
25
  examples/personality_test/chat_with_aristotle.py,sha256=4X_fwubMpd0Eq2rCReS2bgVlUoAqJprjkLXk2Jz6pXU,1774
25
26
  examples/personality_test/tesks_test.py,sha256=7LIiwrEbva9WWZOLi34fsmCBN__RZbPpxoUOKA_AtYk,1924
26
27
  examples/test_local_models/local_chat.py,sha256=slakja2zaHOEAUsn2tn_VmI4kLx6luLBrPqAeaNsix8,456
27
- lollms_client/__init__.py,sha256=bBGPNcYNlEP-IxspmHZvJ3M-GLhfR9DDR1H2xqA3otg,910
28
+ lollms_client/__init__.py,sha256=Q12uBDAS5nze6N8vwIoOrzdr5UQC1wogoKmgO31q2uk,912
28
29
  lollms_client/lollms_config.py,sha256=goEseDwDxYJf3WkYJ4IrLXwg3Tfw73CXV2Avg45M_hE,21876
29
30
  lollms_client/lollms_core.py,sha256=Jr9VQCvyxtsdy3VstNjOOoMCx4uS50VHSzaFuyu754o,118714
30
- lollms_client/lollms_discussion.py,sha256=fUtae7R-PcS2-rwCdd7BPX2FRdgXD8xwBgYs_17SCyA,20802
31
+ lollms_client/lollms_discussion.py,sha256=9QcmDIlzozgBWWuZK2EF0VlK6l3JYCxDfeXaf_KxkBA,20974
31
32
  lollms_client/lollms_js_analyzer.py,sha256=01zUvuO2F_lnUe_0NLxe1MF5aHE1hO8RZi48mNPv-aw,8361
32
33
  lollms_client/lollms_llm_binding.py,sha256=E81g4yBlQn76WTSLicnTETJuQhf_WZUMZaxotgRnOcA,12096
33
34
  lollms_client/lollms_mcp_binding.py,sha256=0rK9HQCBEGryNc8ApBmtOlhKE1Yfn7X7xIQssXxS2Zc,8933
@@ -54,7 +55,7 @@ lollms_client/mcp_bindings/local_mcp/default_tools/file_writer/file_writer.py,sh
54
55
  lollms_client/mcp_bindings/local_mcp/default_tools/generate_image_from_prompt/generate_image_from_prompt.py,sha256=THtZsMxNnXZiBdkwoBlfbWY2C5hhDdmPtnM-8cSKN6s,9488
55
56
  lollms_client/mcp_bindings/local_mcp/default_tools/internet_search/internet_search.py,sha256=PLC31-D04QKTOTb1uuCHnrAlpysQjsk89yIJngK0VGc,4586
56
57
  lollms_client/mcp_bindings/local_mcp/default_tools/python_interpreter/python_interpreter.py,sha256=McDCBVoVrMDYgU7EYtyOY7mCk1uEeTea0PSD69QqDsQ,6228
57
- lollms_client/mcp_bindings/remote_mcp/__init__.py,sha256=L7J_CvpF5ydu_eBVNuxUPViedDgI5jbSqPSy8rQTtYU,13170
58
+ lollms_client/mcp_bindings/remote_mcp/__init__.py,sha256=2oSVcOvW6XkAIfZvCNmwc1dSwrZ4hTZF8mntauM7YnU,16482
58
59
  lollms_client/mcp_bindings/standard_mcp/__init__.py,sha256=zpF4h8cTUxoERI-xcVjmS_V772LK0V4jegjz2k1PK98,31658
59
60
  lollms_client/stt_bindings/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
60
61
  lollms_client/stt_bindings/lollms/__init__.py,sha256=jBz3285atdPRqQe9ZRrb-AvjqKRB4f8tjLXjma0DLfE,6082
@@ -76,8 +77,8 @@ lollms_client/tts_bindings/piper_tts/__init__.py,sha256=0IEWG4zH3_sOkSb9WbZzkeV5
76
77
  lollms_client/tts_bindings/xtts/__init__.py,sha256=FgcdUH06X6ZR806WQe5ixaYx0QoxtAcOgYo87a2qxYc,18266
77
78
  lollms_client/ttv_bindings/__init__.py,sha256=UZ8o2izQOJLQgtZ1D1cXoNST7rzqW22rL2Vufc7ddRc,3141
78
79
  lollms_client/ttv_bindings/lollms/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
79
- lollms_client-0.20.4.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
80
- lollms_client-0.20.4.dist-info/METADATA,sha256=L7jL_v8lKQEjpFLmOVAv97A_Gz_jAqVJMNuO9IimHx4,13374
81
- lollms_client-0.20.4.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
82
- lollms_client-0.20.4.dist-info/top_level.txt,sha256=NI_W8S4OYZvJjb0QWMZMSIpOrYzpqwPGYaklhyWKH2w,23
83
- lollms_client-0.20.4.dist-info/RECORD,,
80
+ lollms_client-0.20.6.dist-info/licenses/LICENSE,sha256=HrhfyXIkWY2tGFK11kg7vPCqhgh5DcxleloqdhrpyMY,11558
81
+ lollms_client-0.20.6.dist-info/METADATA,sha256=NG_lJ-ZX3YXxjvMYclHkNPI4aKvheU8CvcDR9gkqY6I,13374
82
+ lollms_client-0.20.6.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
83
+ lollms_client-0.20.6.dist-info/top_level.txt,sha256=NI_W8S4OYZvJjb0QWMZMSIpOrYzpqwPGYaklhyWKH2w,23
84
+ lollms_client-0.20.6.dist-info/RECORD,,