lionagi 0.17.2__py3-none-any.whl → 0.17.4__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.
lionagi/__init__.py CHANGED
@@ -6,7 +6,6 @@ import logging
6
6
 
7
7
  from pydantic import BaseModel, Field
8
8
 
9
- # Eager imports for commonly used components
10
9
  from . import ln as ln
11
10
  from .operations.node import Operation
12
11
  from .service.imodel import iModel
@@ -35,6 +34,11 @@ def __getattr__(name: str):
35
34
 
36
35
  _lazy_imports["Builder"] = Builder
37
36
  return Builder
37
+ elif name == "load_mcp_tools":
38
+ from .protocols.action.manager import load_mcp_tools
39
+
40
+ _lazy_imports["load_mcp_tools"] = load_mcp_tools
41
+ return load_mcp_tools
38
42
 
39
43
  raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
40
44
 
@@ -12,8 +12,6 @@ from lionagi.utils import to_list
12
12
  from .function_calling import FunctionCalling
13
13
  from .tool import FuncTool, FuncToolRef, Tool, ToolRef
14
14
 
15
- __all__ = ("ActionManager",)
16
-
17
15
 
18
16
  class ActionManager(Manager):
19
17
  """
@@ -67,13 +65,16 @@ class ActionManager(Manager):
67
65
 
68
66
  Args:
69
67
  tool (FuncTool):
70
- A `Tool` object or a raw callable function.
68
+ A `Tool` object, a raw callable function, or an MCP config dict.
69
+ - Tool: Registered directly
70
+ - Callable: Wrapped as Tool(func_callable=...)
71
+ - Dict: Treated as MCP config, Tool(mcp_config=...)
71
72
  update (bool):
72
73
  If True, allow replacing an existing tool with the same name.
73
74
 
74
75
  Raises:
75
76
  ValueError: If tool already registered and update=False.
76
- TypeError: If `tool` is not a Tool or callable.
77
+ TypeError: If `tool` is not a Tool, callable, or dict.
77
78
  """
78
79
  # Check if tool already exists
79
80
  if not update and tool in self:
@@ -82,14 +83,20 @@ class ActionManager(Manager):
82
83
  name = tool.function
83
84
  elif callable(tool):
84
85
  name = tool.__name__
86
+ elif isinstance(tool, dict):
87
+ # For MCP config, extract the tool name (first key)
88
+ name = list(tool.keys())[0] if tool else None
85
89
  raise ValueError(f"Tool {name} is already registered.")
86
90
 
87
- # Convert raw callable to a Tool if needed
91
+ # Convert to Tool object based on type
88
92
  if callable(tool):
89
93
  tool = Tool(func_callable=tool)
90
- if not isinstance(tool, Tool):
94
+ elif isinstance(tool, dict):
95
+ # Dict is treated as MCP config
96
+ tool = Tool(mcp_config=tool)
97
+ elif not isinstance(tool, Tool):
91
98
  raise TypeError(
92
- "Must provide a `Tool` object or a callable function."
99
+ "Must provide a `Tool` object, a callable function, or an MCP config dict."
93
100
  )
94
101
  self.registry[tool.function] = tool
95
102
 
@@ -245,7 +252,258 @@ class ActionManager(Manager):
245
252
  ]
246
253
  raise TypeError(f"Unsupported type {type(tool)}")
247
254
 
255
+ async def register_mcp_server(
256
+ self,
257
+ server_config: dict[str, Any],
258
+ tool_names: list[str] | None = None,
259
+ request_options: dict[str, type] | None = None,
260
+ update: bool = False,
261
+ ) -> list[str]:
262
+ """
263
+ Register tools from an MCP server with automatic discovery.
264
+
265
+ Args:
266
+ server_config: MCP server configuration (command, args, etc.)
267
+ Can be {"server": "name"} to reference loaded config
268
+ or full config dict with command/args
269
+ tool_names: Optional list of specific tool names to register.
270
+ If None, will discover and register all available tools.
271
+ request_options: Optional dict mapping tool names to Pydantic model classes
272
+ for request validation. E.g., {"exa_search": ExaSearchRequest}
273
+ update: If True, allow updating existing tools.
274
+
275
+ Returns:
276
+ List of registered tool names
277
+
278
+ Example:
279
+ # Auto-discover with Pydantic validation
280
+ from lionagi.service.third_party.exa_models import ExaSearchRequest
281
+ tools = await manager.register_mcp_server(
282
+ {"server": "search"},
283
+ request_options={"exa_search": ExaSearchRequest}
284
+ )
285
+
286
+ # Register specific tools only
287
+ tools = await manager.register_mcp_server(
288
+ {"command": "python", "args": ["-m", "server"]},
289
+ tool_names=["search", "fetch"]
290
+ )
291
+ """
292
+ registered_tools = []
293
+
294
+ # Extract server name for qualified naming
295
+ server_name = None
296
+ if isinstance(server_config, dict) and "server" in server_config:
297
+ server_name = server_config["server"]
298
+
299
+ if tool_names:
300
+ # Register specific tools with qualified names
301
+ for tool_name in tool_names:
302
+ # Use qualified name to avoid collisions
303
+ qualified_name = (
304
+ f"{server_name}_{tool_name}" if server_name else tool_name
305
+ )
306
+
307
+ # Store original tool name in config for MCP calls
308
+ config_with_metadata = dict(server_config)
309
+ config_with_metadata["_original_tool_name"] = tool_name
310
+
311
+ mcp_config = {qualified_name: config_with_metadata}
312
+
313
+ # Get request_options for this tool if provided
314
+ tool_request_options = None
315
+ if request_options and tool_name in request_options:
316
+ tool_request_options = request_options[tool_name]
317
+
318
+ # Create tool with request_options for Pydantic validation
319
+ tool = Tool(
320
+ mcp_config=mcp_config, request_options=tool_request_options
321
+ )
322
+ self.register_tool(tool, update=update)
323
+ registered_tools.append(qualified_name)
324
+ else:
325
+ # Auto-discover tools from the server
326
+ from lionagi.service.connections.mcp.wrapper import (
327
+ MCPConnectionPool,
328
+ )
329
+
330
+ # Get client and discover tools
331
+ client = await MCPConnectionPool.get_client(server_config)
332
+ tools = await client.list_tools()
333
+
334
+ # Register each discovered tool with qualified name
335
+ for tool in tools:
336
+ # Use qualified name to avoid collisions: server_toolname
337
+ qualified_name = (
338
+ f"{server_name}_{tool.name}" if server_name else tool.name
339
+ )
340
+
341
+ # Store original tool name in config for MCP calls
342
+ config_with_metadata = dict(server_config)
343
+ config_with_metadata["_original_tool_name"] = tool.name
344
+
345
+ mcp_config = {qualified_name: config_with_metadata}
346
+
347
+ # Get request_options for this tool if provided
348
+ tool_request_options = None
349
+ if request_options and tool.name in request_options:
350
+ tool_request_options = request_options[tool.name]
351
+
352
+ try:
353
+ # Create tool with request_options for Pydantic validation
354
+ tool_obj = Tool(
355
+ mcp_config=mcp_config,
356
+ request_options=tool_request_options,
357
+ )
358
+ self.register_tool(tool_obj, update=update)
359
+ registered_tools.append(qualified_name)
360
+ except Exception as e:
361
+ print(
362
+ f"Warning: Failed to register tool {qualified_name}: {e}"
363
+ )
364
+
365
+ return registered_tools
366
+
367
+ async def load_mcp_config(
368
+ self,
369
+ config_path: str,
370
+ server_names: list[str] | None = None,
371
+ update: bool = False,
372
+ ) -> dict[str, list[str]]:
373
+ """
374
+ Load MCP configurations from a .mcp.json file with auto-discovery.
375
+
376
+ Args:
377
+ config_path: Path to .mcp.json configuration file
378
+ server_names: Optional list of server names to load.
379
+ If None, loads all servers.
380
+ update: If True, allow updating existing tools.
381
+
382
+ Returns:
383
+ Dict mapping server names to lists of registered tool names
384
+
385
+ Example:
386
+ # Load all servers and auto-discover their tools
387
+ tools = await manager.load_mcp_config("/path/to/.mcp.json")
388
+
389
+ # Load specific servers only
390
+ tools = await manager.load_mcp_config(
391
+ "/path/to/.mcp.json",
392
+ server_names=["search", "memory"]
393
+ )
394
+ """
395
+ from lionagi.service.connections.mcp.wrapper import MCPConnectionPool
396
+
397
+ # Load the config file into the connection pool
398
+ MCPConnectionPool.load_config(config_path)
399
+
400
+ # Get server list to process
401
+ if server_names is None:
402
+ # Get all server names from loaded config
403
+ # The config has already been validated by load_config
404
+ server_names = list(MCPConnectionPool._configs.keys())
405
+
406
+ # Register tools from each server
407
+ all_tools = {}
408
+ for server_name in server_names:
409
+ try:
410
+ # Register using server reference
411
+ tools = await self.register_mcp_server(
412
+ {"server": server_name}, update=update
413
+ )
414
+ all_tools[server_name] = tools
415
+ print(
416
+ f"✅ Registered {len(tools)} tools from server '{server_name}'"
417
+ )
418
+ except Exception as e:
419
+ print(f"⚠️ Failed to register server '{server_name}': {e}")
420
+ all_tools[server_name] = []
421
+
422
+ return all_tools
423
+
424
+
425
+ async def load_mcp_tools(
426
+ config_path: str | None = None,
427
+ server_names: list[str] | None = None,
428
+ request_options_map: dict[str, dict[str, type]] | None = None,
429
+ update: bool = False,
430
+ ) -> list[Tool]:
431
+ """
432
+ Standalone helper function to load MCP tools from servers.
433
+ Creates an ActionManager internally and returns tools ready for use.
434
+
435
+ Args:
436
+ config_path: Path to .mcp.json file. If None, assumes config already loaded.
437
+ server_names: Optional list of server names to load.
438
+ If None, loads all servers from config.
439
+ request_options_map: Optional dict mapping server names to tool request options.
440
+ E.g., {"search": {"exa_search": ExaSearchRequest}}
441
+ update: If True, allow updating existing tools.
442
+
443
+ Returns:
444
+ List of Tool objects ready to use with Branch
445
+
446
+ Example:
447
+ # Simple one-liner to get MCP tools
448
+ from lionagi.protocols.action.manager import load_mcp_tools
449
+ from lionagi.service.third_party.exa_models import ExaSearchRequest
450
+ from lionagi.service.third_party.pplx_models import PerplexityChatRequest
451
+
452
+ # Load with Pydantic validation
453
+ tools = await load_mcp_tools(
454
+ "/path/to/.mcp.json",
455
+ ["search"],
456
+ request_options_map={
457
+ "search": {
458
+ "exa_search": ExaSearchRequest,
459
+ "perplexity_search": PerplexityChatRequest
460
+ }
461
+ }
462
+ )
463
+ branch = Branch(tools=tools)
464
+ """
465
+ from lionagi.service.connections.mcp.wrapper import MCPConnectionPool
466
+
467
+ # Create a temporary ActionManager for tool management
468
+ manager = ActionManager()
469
+
470
+ # Load config if provided
471
+ if config_path:
472
+ MCPConnectionPool.load_config(config_path)
473
+
474
+ # If no server names specified, discover from config
475
+ if server_names is None and config_path:
476
+ # Get all server names from loaded config
477
+ server_names = list(MCPConnectionPool._configs.keys())
478
+
479
+ if server_names is None:
480
+ raise ValueError(
481
+ "Either provide server_names or config_path to discover servers"
482
+ )
483
+
484
+ # Register all servers
485
+ for server_name in server_names:
486
+ try:
487
+ # Get request_options for this server if provided
488
+ request_options = None
489
+ if request_options_map and server_name in request_options_map:
490
+ request_options = request_options_map[server_name]
491
+
492
+ tools_registered = await manager.register_mcp_server(
493
+ {"server": server_name},
494
+ request_options=request_options,
495
+ update=update,
496
+ )
497
+ print(
498
+ f"✅ Loaded {len(tools_registered)} tools from {server_name}"
499
+ )
500
+ except Exception as e:
501
+ print(f"⚠️ Failed to load server '{server_name}': {e}")
502
+
503
+ # Return all registered tools as a list
504
+ return list(manager.registry.values())
505
+
248
506
 
249
- __all__ = ["ActionManager"]
507
+ __all__ = ["ActionManager", "load_mcp_tools"]
250
508
 
251
509
  # File: lionagi/protocols/action/manager.py
@@ -7,11 +7,9 @@ import inspect
7
7
  from collections.abc import Callable
8
8
  from typing import Any, TypeAlias
9
9
 
10
- from pydantic import Field, field_validator, model_validator
10
+ from pydantic import Field, model_validator
11
11
  from typing_extensions import Self
12
12
 
13
- from lionagi.libs.schema.function_to_schema import function_to_schema
14
- from lionagi.libs.validate.common_field_validators import validate_callable
15
13
  from lionagi.protocols.generic.element import Element
16
14
 
17
15
  __all__ = (
@@ -39,6 +37,9 @@ class Tool(Element):
39
37
  exclude=True,
40
38
  )
41
39
 
40
+ mcp_config: dict[str, dict[str, Any]] | None = None
41
+ """{tool_name: mcp_config dict}"""
42
+
42
43
  tool_schema: dict[str, Any] | None = Field(
43
44
  default=None,
44
45
  description="Schema describing the function's parameters and structure",
@@ -78,15 +79,46 @@ class Tool(Element):
78
79
  description="Whether to enforce strict validation of function parameters",
79
80
  )
80
81
 
81
- @field_validator("func_callable", mode="before")
82
- def _validate_func_callable(cls, value: Any) -> Callable[..., Any]:
83
- return validate_callable(
84
- cls, value, undefind_able=False, check_name=True
85
- )
82
+ @model_validator(mode="before")
83
+ def _validate_callable_config(cls, data):
84
+ mcp_config = data.get("mcp_config")
85
+ func_callable = data.get("func_callable")
86
+
87
+ if mcp_config is not None:
88
+ if func_callable is not None:
89
+ raise ValueError(
90
+ "`mcp_config` and `func_callable` cannot both be set."
91
+ )
92
+ if not isinstance(mcp_config, dict):
93
+ raise ValueError("`mcp_config` must be a dictionary.")
94
+ if len(mcp_config) != 1:
95
+ raise ValueError(
96
+ "`mcp_config` must contain exactly one entry."
97
+ )
98
+ tool_name, config = next(iter(mcp_config.items()))
99
+
100
+ from lionagi.service.connections.mcp.wrapper import create_mcp_tool
101
+
102
+ func_callable = create_mcp_tool(config, tool_name)
103
+ else:
104
+ from lionagi.libs.validate.common_field_validators import (
105
+ validate_callable,
106
+ )
107
+
108
+ validate_callable(
109
+ cls, func_callable, undefind_able=False, check_name=True
110
+ )
111
+
112
+ data["func_callable"] = func_callable
113
+ return data
86
114
 
87
115
  @model_validator(mode="after")
88
116
  def _validate_tool_schema(self) -> Self:
89
117
  if self.tool_schema is None:
118
+ from lionagi.libs.schema.function_to_schema import (
119
+ function_to_schema,
120
+ )
121
+
90
122
  self.tool_schema = function_to_schema(
91
123
  self.func_callable, request_options=self.request_options
92
124
  )
@@ -117,10 +149,9 @@ class Tool(Element):
117
149
  ).parameters.items()
118
150
  if v.default == inspect.Parameter.empty
119
151
  }
120
- if "kwargs" in a:
121
- a.remove("kwargs")
122
- if "args" in a:
123
- a.remove("args")
152
+ for i in ("kw", "kwargs", "args"):
153
+ if i in a:
154
+ a.remove(i)
124
155
  return a
125
156
  except Exception:
126
157
  return set()
@@ -142,8 +173,8 @@ class Tool(Element):
142
173
  return dict_
143
174
 
144
175
 
145
- FuncTool: TypeAlias = Tool | Callable[..., Any]
146
- """Represents either a `Tool` instance or a raw callable function."""
176
+ FuncTool: TypeAlias = Tool | Callable[..., Any] | dict
177
+ """Represents either a `Tool` instance, a raw callable function or mcp config."""
147
178
 
148
179
  FuncToolRef: TypeAlias = FuncTool | str
149
180
  """
@@ -157,19 +188,4 @@ Used for specifying one or more tool references, or a boolean
157
188
  indicating 'all' or 'none'.
158
189
  """
159
190
 
160
-
161
- def func_to_tool(func: Callable[..., Any], **kwargs) -> Tool:
162
- """
163
- Convenience function that wraps a raw function in a `Tool`.
164
-
165
- Args:
166
- func (Callable[..., Any]): The function to wrap.
167
- **kwargs: Additional arguments passed to the `Tool` constructor.
168
-
169
- Returns:
170
- Tool: A new Tool instance wrapping `func`.
171
- """
172
- return Tool(func_callable=func, **kwargs)
173
-
174
-
175
191
  # File: lionagi/protocols/action/tool.py
@@ -1048,7 +1048,12 @@ class Pile(Element, Collective[T], Generic[T], Adaptable, AsyncAdaptable):
1048
1048
 
1049
1049
  def to_df(self, columns: list[str] | None = None, **kw: Any):
1050
1050
  """Convert to DataFrame."""
1051
- from pydapter.extras.pandas_ import DataFrameAdapter
1051
+ try:
1052
+ from pydapter.extras.pandas_ import DataFrameAdapter
1053
+ except ImportError as e:
1054
+ raise ImportError(
1055
+ "pandas is required for to_df(). Please install it via `pip install pandas`."
1056
+ ) from e
1052
1057
 
1053
1058
  df = DataFrameAdapter.to_obj(
1054
1059
  list(self.collections.values()), adapt_meth="to_dict", **kw
@@ -198,5 +198,11 @@ class AssistantResponse(RoledMessage):
198
198
  sender=sender, recipient=recipient, template=template, **kwargs
199
199
  )
200
200
 
201
+ def as_context(self) -> dict:
202
+ return f"""
203
+ Response: {self.response or "Not available"}
204
+ Summary: {self.model_response.get("summary") or "Not available"}
205
+ """.strip()
206
+
201
207
 
202
208
  # File: lionagi/protocols/messages/assistant_response.py
@@ -0,0 +1,3 @@
1
+ # Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,259 @@
1
+ # Copyright (c) 2025, HaiyangLi <quantocean.li at gmail dot com>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Any, Dict
11
+
12
+ try:
13
+ from fastmcp import Client as FastMCPClient
14
+
15
+ FASTMCP_AVAILABLE = True
16
+ except ImportError:
17
+ FASTMCP_AVAILABLE = False
18
+ FastMCPClient = None
19
+
20
+ # Suppress MCP server logging by default
21
+ logging.getLogger("mcp").setLevel(logging.WARNING)
22
+ logging.getLogger("fastmcp").setLevel(logging.WARNING)
23
+ logging.getLogger("mcp.server").setLevel(logging.WARNING)
24
+ logging.getLogger("mcp.server.lowlevel").setLevel(logging.WARNING)
25
+ logging.getLogger("mcp.server.lowlevel.server").setLevel(logging.WARNING)
26
+
27
+
28
+ class MCPConnectionPool:
29
+ """Simple connection pool for MCP clients.
30
+
31
+ Security Model:
32
+ This class trusts user-provided MCP server configurations, similar to how
33
+ development tools trust configured language servers or extensions. Users are
34
+ responsible for vetting the MCP servers they choose to run.
35
+
36
+ For enhanced security in production:
37
+ - Run MCP servers in sandboxed environments (containers, VMs)
38
+ - Use process isolation and resource limits
39
+ - Monitor server behavior and resource usage
40
+ - Validate server outputs before use
41
+ """
42
+
43
+ _clients: dict[str, Any] = {}
44
+ _configs: dict[str, dict] = {}
45
+ _lock = asyncio.Lock()
46
+
47
+ async def __aenter__(self):
48
+ """Context manager entry."""
49
+ return self
50
+
51
+ async def __aexit__(self, *_):
52
+ """Context manager exit - cleanup connections."""
53
+ await self.cleanup()
54
+
55
+ @classmethod
56
+ def load_config(cls, path: str = ".mcp.json") -> None:
57
+ """Load MCP server configurations from file.
58
+
59
+ Args:
60
+ path: Path to .mcp.json configuration file
61
+
62
+ Raises:
63
+ FileNotFoundError: If config file doesn't exist
64
+ json.JSONDecodeError: If config file has invalid JSON
65
+ ValueError: If config structure is invalid
66
+ """
67
+ config_path = Path(path)
68
+ if not config_path.exists():
69
+ raise FileNotFoundError(f"MCP config file not found: {path}")
70
+
71
+ try:
72
+ with open(config_path) as f:
73
+ data = json.load(f)
74
+ except json.JSONDecodeError as e:
75
+ raise json.JSONDecodeError(
76
+ f"Invalid JSON in MCP config file: {e.msg}", e.doc, e.pos
77
+ )
78
+
79
+ if not isinstance(data, dict):
80
+ raise ValueError("MCP config must be a JSON object")
81
+
82
+ servers = data.get("mcpServers", {})
83
+ if not isinstance(servers, dict):
84
+ raise ValueError("mcpServers must be a dictionary")
85
+
86
+ cls._configs.update(servers)
87
+
88
+ @classmethod
89
+ async def get_client(cls, server_config: dict[str, Any]) -> Any:
90
+ """Get or create a pooled MCP client."""
91
+ if not FASTMCP_AVAILABLE:
92
+ raise ImportError(
93
+ "FastMCP not installed. Run: pip install fastmcp"
94
+ )
95
+
96
+ # Generate unique key for this config
97
+ if "server" in server_config:
98
+ # Server reference from .mcp.json
99
+ server_name = server_config["server"]
100
+ if server_name not in cls._configs:
101
+ # Try loading config
102
+ cls.load_config()
103
+ if server_name not in cls._configs:
104
+ raise ValueError(f"Unknown MCP server: {server_name}")
105
+
106
+ config = cls._configs[server_name]
107
+ cache_key = f"server:{server_name}"
108
+ else:
109
+ # Inline config - use command as key
110
+ config = server_config
111
+ cache_key = f"inline:{config.get('command')}:{id(config)}"
112
+
113
+ # Check if client exists and is connected
114
+ async with cls._lock:
115
+ if cache_key in cls._clients:
116
+ client = cls._clients[cache_key]
117
+ # Simple connectivity check
118
+ if hasattr(client, "is_connected") and client.is_connected():
119
+ return client
120
+ else:
121
+ # Remove stale client
122
+ del cls._clients[cache_key]
123
+
124
+ # Create new client
125
+ client = await cls._create_client(config)
126
+ cls._clients[cache_key] = client
127
+ return client
128
+
129
+ @classmethod
130
+ async def _create_client(cls, config: dict[str, Any]) -> Any:
131
+ """Create a new MCP client from config.
132
+
133
+ Security Note:
134
+ MCP servers are explicitly configured by users via .mcp.json files or API calls.
135
+ The security model trusts user-provided configurations, similar to how IDEs trust
136
+ configured language servers. For additional security, run MCP servers in sandboxed
137
+ environments (containers, VMs) rather than restricting commands at the library level.
138
+
139
+ Args:
140
+ config: Server configuration with 'url' or 'command' + optional 'args' and 'env'
141
+
142
+ Raises:
143
+ ValueError: If config format is invalid
144
+ """
145
+ # Validate config structure
146
+ if not isinstance(config, dict):
147
+ raise ValueError("Config must be a dictionary")
148
+
149
+ if not any(k in config for k in ["url", "command"]):
150
+ raise ValueError("Config must have either 'url' or 'command' key")
151
+
152
+ # Handle different config formats
153
+ if "url" in config:
154
+ # Direct URL connection
155
+ client = FastMCPClient(config["url"])
156
+ elif "command" in config:
157
+ # Command-based connection
158
+ # Validate args if provided
159
+ args = config.get("args", [])
160
+ if not isinstance(args, list):
161
+ raise ValueError("Config 'args' must be a list")
162
+
163
+ # Merge environment variables - user config takes precedence
164
+ env = os.environ.copy()
165
+ env.update(config.get("env", {}))
166
+
167
+ # Suppress server logging unless debug mode is enabled
168
+ if not (
169
+ config.get("debug", False)
170
+ or os.environ.get("MCP_DEBUG", "").lower() == "true"
171
+ ):
172
+ # Common environment variables to suppress logging
173
+ env.setdefault("LOG_LEVEL", "ERROR")
174
+ env.setdefault("PYTHONWARNINGS", "ignore")
175
+ env.setdefault("KHIVEMCP_LOG_LEVEL", "ERROR")
176
+ # Suppress FastMCP server logs
177
+ env.setdefault("FASTMCP_QUIET", "true")
178
+ env.setdefault("MCP_QUIET", "true")
179
+
180
+ # Create client with command
181
+ from fastmcp.client.transports import StdioTransport
182
+
183
+ transport = StdioTransport(
184
+ command=config["command"],
185
+ args=args,
186
+ env=env,
187
+ )
188
+ client = FastMCPClient(transport)
189
+ else:
190
+ raise ValueError("Config must have 'url' or 'command'")
191
+
192
+ # Initialize connection
193
+ await client.__aenter__()
194
+ return client
195
+
196
+ @classmethod
197
+ async def cleanup(cls):
198
+ """Clean up all pooled connections."""
199
+ async with cls._lock:
200
+ for cache_key, client in cls._clients.items():
201
+ try:
202
+ await client.__aexit__(None, None, None)
203
+ except Exception as e:
204
+ # Log cleanup errors for debugging while continuing cleanup
205
+ logging.debug(
206
+ f"Error cleaning up MCP client {cache_key}: {e}"
207
+ )
208
+ cls._clients.clear()
209
+
210
+
211
+ def create_mcp_tool(mcp_config: dict[str, Any], tool_name: str) -> Any:
212
+ """Create a callable that wraps MCP tool execution.
213
+
214
+ Args:
215
+ mcp_config: MCP server configuration (server reference or inline)
216
+ tool_name: Name of the tool (can be qualified like "server_toolname")
217
+
218
+ Returns:
219
+ Async callable that executes the MCP tool
220
+ """
221
+
222
+ async def mcp_callable(**kwargs):
223
+ """Execute MCP tool with connection pooling."""
224
+ # Extract the original tool name if it was stored in metadata
225
+ actual_tool_name = mcp_config.get("_original_tool_name", tool_name)
226
+
227
+ # Remove metadata before getting client
228
+ config_for_client = {
229
+ k: v for k, v in mcp_config.items() if not k.startswith("_")
230
+ }
231
+
232
+ client = await MCPConnectionPool.get_client(config_for_client)
233
+
234
+ # Call the tool with the original name
235
+ result = await client.call_tool(actual_tool_name, kwargs)
236
+
237
+ # Handle different result types
238
+ if hasattr(result, "content"):
239
+ # CallToolResult object - extract content
240
+ content = result.content
241
+ if isinstance(content, list) and len(content) == 1:
242
+ item = content[0]
243
+ if hasattr(item, "text"):
244
+ return item.text
245
+ elif isinstance(item, dict) and item.get("type") == "text":
246
+ return item.get("text", "")
247
+ return content
248
+ elif isinstance(result, list) and len(result) == 1:
249
+ item = result[0]
250
+ if isinstance(item, dict) and item.get("type") == "text":
251
+ return item.get("text", "")
252
+
253
+ return result
254
+
255
+ # Set function metadata for Tool introspection
256
+ mcp_callable.__name__ = tool_name
257
+ mcp_callable.__doc__ = f"MCP tool: {tool_name}"
258
+
259
+ return mcp_callable
lionagi/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.17.2"
1
+ __version__ = "0.17.4"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lionagi
3
- Version: 0.17.2
3
+ Version: 0.17.4
4
4
  Summary: An Intelligence Operating System.
5
5
  Author-email: HaiyangLi <quantocean.li@gmail.com>
6
6
  License: Apache License
@@ -374,6 +374,33 @@ print(result)
374
374
  The LLM can now open the PDF, read in slices, fetch references, and produce a
375
375
  final structured summary.
376
376
 
377
+ ### MCP (Model Context Protocol) Integration
378
+
379
+ LionAGI supports Anthropic's Model Context Protocol for seamless tool integration:
380
+
381
+ ```
382
+ pip install "lionagi[mcp]"
383
+ ```
384
+
385
+ ```python
386
+ from lionagi import load_mcp_tools
387
+
388
+ # Load tools from any MCP server
389
+ tools = await load_mcp_tools(".mcp.json", ["search", "memory"])
390
+
391
+ # Use with ReAct reasoning
392
+ branch = Branch(chat_model=gpt4o, tools=tools)
393
+ result = await branch.ReAct(
394
+ instruct={"instruction": "Research recent AI developments"},
395
+ tools=["search_exa_search"],
396
+ max_extensions=3
397
+ )
398
+ ```
399
+
400
+ - **Dynamic Discovery**: Auto-discover and register tools from MCP servers
401
+ - **Type Safety**: Full Pydantic validation for tool interactions
402
+ - **Connection Pooling**: Efficient resource management with automatic reuse
403
+
377
404
  ### Observability & Debugging
378
405
 
379
406
  - Inspect messages:
@@ -1,4 +1,4 @@
1
- lionagi/__init__.py,sha256=KDyBBo2Ahdk44akAbUG7ZZATdZhVvl8WHEr6HXMC6vA,1196
1
+ lionagi/__init__.py,sha256=Kh_2iWIL1mvvtMAETJ5cRxIxRkiAERYAJmK1IOgNtvQ,1335
2
2
  lionagi/_class_registry.py,sha256=pfUO1DjFZIqr3OwnNMkFqL_fiEBrrf8-swkGmP_KDLE,3112
3
3
  lionagi/_errors.py,sha256=ia_VWhPSyr5FIJLSdPpl04SrNOLI2skN40VC8ePmzeQ,3748
4
4
  lionagi/_types.py,sha256=COWRrmstmABGKKn-h_cKiAREGsMp_Ik49OdR4lSS3P8,1263
@@ -6,7 +6,7 @@ lionagi/config.py,sha256=D13nnjpgJKz_LlQrzaKKVefm4hqesz_dP9ROjWmGuLE,3811
6
6
  lionagi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
7
  lionagi/settings.py,sha256=HDuKCEJCpc4HudKodBnhoQUGuTGhRHdlIFhbtf3VBtY,1633
8
8
  lionagi/utils.py,sha256=pfAibR84sx-aPxGNPrdlHqUAf2OXoCBGRCMseMrzhi4,18046
9
- lionagi/version.py,sha256=I5u6uLh7NsbyGkKLvVteW175Afe6HJt5UsjBZ28EGks,23
9
+ lionagi/version.py,sha256=LisYi6U4jD61j06adgCNdZJfR0RhF_Im0hPrka6lSjc,23
10
10
  lionagi/adapters/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
11
  lionagi/adapters/_utils.py,sha256=sniMG1LDDkwJNzUF2K32jv7rA6Y1QcohgyNclYsptzI,453
12
12
  lionagi/adapters/async_postgres_adapter.py,sha256=2XlxYNPow78dFHIQs8W1oJ2zkVD5Udn3aynMBF9Nf3k,3498
@@ -109,8 +109,8 @@ lionagi/protocols/ids.py,sha256=RM40pP_4waMJcfCGmEK_PfS8-k_DuDbC1fG-2Peuf6s,2472
109
109
  lionagi/protocols/types.py,sha256=6GJ5ZgDyWl8tckNLXB0z8jbtkordWpgm5r4ht31KVRc,2431
110
110
  lionagi/protocols/action/__init__.py,sha256=5y5joOZzfFWERl75auAcNcKC3lImVJ5ZZGvvHZUFCJM,112
111
111
  lionagi/protocols/action/function_calling.py,sha256=jFy6Ruh3QWkERtBHXqPWVwrOH5aDitEo8oJHZgW5xnI,5066
112
- lionagi/protocols/action/manager.py,sha256=XmdQIaVgSpmFBFW9kbW_rdwdQmlBteP4VRanxh_f918,8549
113
- lionagi/protocols/action/tool.py,sha256=h2FAY1b8y3LXrvAtfFhvdv1nu8cwz2knUeRCi2G9k1E,5243
112
+ lionagi/protocols/action/manager.py,sha256=akq3HuLUuTRkm7ENmn8oycNH-4ksfekXOwOJky5OBrE,18835
113
+ lionagi/protocols/action/tool.py,sha256=_o7rLokD3poupqst6YoQqEC6RvePDueySoVrGK2S538,5850
114
114
  lionagi/protocols/forms/__init__.py,sha256=5y5joOZzfFWERl75auAcNcKC3lImVJ5ZZGvvHZUFCJM,112
115
115
  lionagi/protocols/forms/base.py,sha256=1J8UU2LXm1Lax5McJos0xjZTzMVLYQWd_hwtxI2wSuM,2838
116
116
  lionagi/protocols/forms/flow.py,sha256=t9Ycvmb-stj0rCyvXXwd7WBcDtuCCTEKYst2aqFDVag,2702
@@ -120,7 +120,7 @@ lionagi/protocols/generic/__init__.py,sha256=5y5joOZzfFWERl75auAcNcKC3lImVJ5ZZGv
120
120
  lionagi/protocols/generic/element.py,sha256=yD4SWNOGZsLTgxtz6TKtJfqkUvDmUH9wIH3Liw6QmmA,15839
121
121
  lionagi/protocols/generic/event.py,sha256=n5EraAJ5bPiwegTdkxsILs7-vFJwmnXhB2om4xMQnK0,6529
122
122
  lionagi/protocols/generic/log.py,sha256=AnPr2RweSZcJdxOK0KGb1eyvAPZmME3hEm9HraUoftQ,8212
123
- lionagi/protocols/generic/pile.py,sha256=AHxj1q5apabATmdgGXlAhkcqIug9r_75wmufdq9XLt4,36894
123
+ lionagi/protocols/generic/pile.py,sha256=rB13BzHhGHcJlijXmKNUdTS1WzcBzpMM1wycPv9ogk4,37090
124
124
  lionagi/protocols/generic/processor.py,sha256=uiNIldAYPEujuboLFyrIJADMlhRghy3H86hYintj5D4,11705
125
125
  lionagi/protocols/generic/progression.py,sha256=HCV_EnQCFvjg6D7eF4ygGrZNQPEOtu75zvW1sJbAVrM,15190
126
126
  lionagi/protocols/graph/__init__.py,sha256=UPu3OmUpjSgX2aBuBJUdG2fppGlfqAH96hU0qIMBMp0,253
@@ -136,7 +136,7 @@ lionagi/protocols/mail/package.py,sha256=qcKRWP5Lk3wTK_rJhRsXFQhWz0pb2Clcs7vJcMw
136
136
  lionagi/protocols/messages/__init__.py,sha256=5y5joOZzfFWERl75auAcNcKC3lImVJ5ZZGvvHZUFCJM,112
137
137
  lionagi/protocols/messages/action_request.py,sha256=-tG9ViTSiZ2beM0onE6C552H5oth-sHLHP6KsBV8OMk,6868
138
138
  lionagi/protocols/messages/action_response.py,sha256=SM3_vA9QPXz3Gc9uPhXm3zOXYheHZZGtl67Yff48Vu8,5272
139
- lionagi/protocols/messages/assistant_response.py,sha256=jrzRPVHHDnPw86Xp0IHnPy0tOZdsk7rBwhG5xnBXAjs,6912
139
+ lionagi/protocols/messages/assistant_response.py,sha256=e7seqUyYCXwInQrHWhNwIf9y40hgP_mH_-9-juItflw,7121
140
140
  lionagi/protocols/messages/base.py,sha256=Ng1Q8yIIIFauUv53LnwDeyOrM-cSCfsHM1GwkxChf2o,2317
141
141
  lionagi/protocols/messages/instruction.py,sha256=Qpme1oSc406V3-F2iOyqC8TVT2GWVduZaoDfdGdBnDI,21127
142
142
  lionagi/protocols/messages/manager.py,sha256=ABDiN-QULbfbSrHVlPe3kqBxr7e7sYoT49wHQMLiT6c,16897
@@ -165,6 +165,8 @@ lionagi/service/connections/endpoint.py,sha256=0r4-8NPyAvLNey09BBsUr5KGJCXchBmVZ
165
165
  lionagi/service/connections/endpoint_config.py,sha256=6sA06uCzriT6p0kFxhDCFH8N6V6MVp8ytlOw5ctBhDI,5169
166
166
  lionagi/service/connections/header_factory.py,sha256=IYeTQQk7r8FXcdhmW7orCxHjNO-Nb1EOXhgNK7CAp-I,1821
167
167
  lionagi/service/connections/match_endpoint.py,sha256=QlOw9CbR1peExP-b-XlkRpqqGIksfNefI2EZCw9P7_E,2575
168
+ lionagi/service/connections/mcp/__init__.py,sha256=3lzOakDoBWmMaNnT2g-YwktPKa_Wme4lnPRSmOQfayY,105
169
+ lionagi/service/connections/mcp/wrapper.py,sha256=fqH2FtZa0mAehXTAfFZrxUvlj-WUSZSUWlcZb1XjkUc,9322
168
170
  lionagi/service/connections/providers/__init__.py,sha256=3lzOakDoBWmMaNnT2g-YwktPKa_Wme4lnPRSmOQfayY,105
169
171
  lionagi/service/connections/providers/anthropic_.py,sha256=vok8mIyFiuV3K83tOjdYfruA6cv1h_57ML6RtpuW-bU,3157
170
172
  lionagi/service/connections/providers/claude_code_cli.py,sha256=kqEOnCUOOh2O_3NGi6W7r-gdLsbW-Jcp11tm30VEv4Q,4455
@@ -195,7 +197,7 @@ lionagi/tools/base.py,sha256=hEGnE4MD0CM4UqnF0xsDRKB0aM-pyrTFHl8utHhyJLU,1897
195
197
  lionagi/tools/types.py,sha256=XtJLY0m-Yi_ZLWhm0KycayvqMCZd--HxfQ0x9vFUYDE,230
196
198
  lionagi/tools/file/__init__.py,sha256=5y5joOZzfFWERl75auAcNcKC3lImVJ5ZZGvvHZUFCJM,112
197
199
  lionagi/tools/file/reader.py,sha256=2YKgU3VKo76zfL_buDAUQJoPLC56f6WJ4_mdJjlMDIM,9509
198
- lionagi-0.17.2.dist-info/METADATA,sha256=AXGNj_mj3gEEDpn7gAtSO5t7W1dkhW_8LVbfSxfbP7A,22674
199
- lionagi-0.17.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
200
- lionagi-0.17.2.dist-info/licenses/LICENSE,sha256=VXFWsdoN5AAknBCgFqQNgPWYx7OPp-PFEP961zGdOjc,11288
201
- lionagi-0.17.2.dist-info/RECORD,,
200
+ lionagi-0.17.4.dist-info/METADATA,sha256=mVBP2MOOqq5vysEk8mmDMc76OpXbNuIFoqSYjBVyhDI,23432
201
+ lionagi-0.17.4.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
202
+ lionagi-0.17.4.dist-info/licenses/LICENSE,sha256=VXFWsdoN5AAknBCgFqQNgPWYx7OPp-PFEP961zGdOjc,11288
203
+ lionagi-0.17.4.dist-info/RECORD,,