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 +5 -1
- lionagi/protocols/action/manager.py +266 -8
- lionagi/protocols/action/tool.py +45 -29
- lionagi/protocols/generic/pile.py +6 -1
- lionagi/protocols/messages/assistant_response.py +6 -0
- lionagi/service/connections/mcp/__init__.py +3 -0
- lionagi/service/connections/mcp/wrapper.py +259 -0
- lionagi/version.py +1 -1
- {lionagi-0.17.2.dist-info → lionagi-0.17.4.dist-info}/METADATA +28 -1
- {lionagi-0.17.2.dist-info → lionagi-0.17.4.dist-info}/RECORD +12 -10
- {lionagi-0.17.2.dist-info → lionagi-0.17.4.dist-info}/WHEEL +0 -0
- {lionagi-0.17.2.dist-info → lionagi-0.17.4.dist-info}/licenses/LICENSE +0 -0
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
|
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
|
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
|
91
|
+
# Convert to Tool object based on type
|
88
92
|
if callable(tool):
|
89
93
|
tool = Tool(func_callable=tool)
|
90
|
-
|
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
|
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
|
lionagi/protocols/action/tool.py
CHANGED
@@ -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,
|
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
|
-
@
|
82
|
-
def
|
83
|
-
|
84
|
-
|
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
|
-
|
121
|
-
a
|
122
|
-
|
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
|
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
|
-
|
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,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.
|
1
|
+
__version__ = "0.17.4"
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: lionagi
|
3
|
-
Version: 0.17.
|
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=
|
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=
|
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=
|
113
|
-
lionagi/protocols/action/tool.py,sha256=
|
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=
|
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=
|
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.
|
199
|
-
lionagi-0.17.
|
200
|
-
lionagi-0.17.
|
201
|
-
lionagi-0.17.
|
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,,
|
File without changes
|
File without changes
|