yaicli 0.6.4__py3-none-any.whl → 0.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,127 @@
1
+ from typing import Any, Dict, List, Tuple, cast
2
+
3
+ from json_repair import repair_json
4
+ from mcp import types
5
+ from rich.panel import Panel
6
+
7
+ from ..config import cfg
8
+ from ..console import get_console
9
+ from ..schemas import ToolCall
10
+ from .function import get_function, list_functions
11
+ from .mcp import MCP_TOOL_NAME_PREFIX, get_mcp, get_mcp_manager, parse_mcp_tool_name
12
+
13
+ console = get_console()
14
+
15
+
16
+ def get_openai_schemas() -> List[Dict[str, Any]]:
17
+ """Get OpenAI-compatible function schemas
18
+
19
+ Returns:
20
+ List of function schemas in OpenAI format
21
+ """
22
+ transformed_schemas = []
23
+ for function in list_functions():
24
+ schema = {
25
+ "type": "function",
26
+ "function": {
27
+ "name": function.name,
28
+ "description": function.description,
29
+ "parameters": function.parameters,
30
+ },
31
+ }
32
+ transformed_schemas.append(schema)
33
+ return transformed_schemas
34
+
35
+
36
+ def get_openai_mcp_tools() -> list[dict[str, Any]]:
37
+ """Get OpenAI-compatible function schemas
38
+
39
+ Returns:
40
+ List of function schemas in OpenAI format
41
+ """
42
+ return get_mcp_manager().to_openai_tools()
43
+
44
+
45
+ def execute_mcp_tool(tool_name: str, tool_kwargs: dict) -> str:
46
+ """Execute an MCP tool
47
+
48
+ Args:
49
+ tool_name: The name of the tool to execute
50
+ tool_kwargs: The arguments to pass to the tool
51
+ """
52
+ manager = get_mcp_manager()
53
+ tool = manager.get_tool(tool_name)
54
+ try:
55
+ result = tool.execute(**tool_kwargs)
56
+ if isinstance(result, list) and len(result) > 0:
57
+ result = result[0]
58
+ if isinstance(result, types.TextContent):
59
+ return result.text
60
+ else:
61
+ return str(result)
62
+ except Exception as e:
63
+ error_msg = f"Call MCP tool error:\nTool name: {tool_name!r}\nArguments: {tool_kwargs!r}\nError: {e}"
64
+ console.print(error_msg, style="red")
65
+ return error_msg
66
+
67
+
68
+ def execute_tool_call(tool_call: ToolCall) -> Tuple[str, bool]:
69
+ """Execute a tool call and return the result
70
+
71
+ Args:
72
+ tool_call: The tool call to execute
73
+
74
+ Returns:
75
+ Tuple[str, bool]: (result text, success flag)
76
+ """
77
+ is_function_call = not tool_call.name.startswith(MCP_TOOL_NAME_PREFIX)
78
+ if is_function_call:
79
+ get_tool_func = get_function
80
+ show_output = cfg["SHOW_FUNCTION_OUTPUT"]
81
+ _type = "function"
82
+ else:
83
+ tool_call.name = parse_mcp_tool_name(tool_call.name)
84
+ get_tool_func = get_mcp
85
+ show_output = cfg["SHOW_MCP_OUTPUT"]
86
+ _type = "mcp"
87
+
88
+ console.print(f"@{_type.title()} call: {tool_call.name}({tool_call.arguments})", style="blue")
89
+ # 1. Get the tool
90
+ try:
91
+ tool = get_tool_func(tool_call.name)
92
+ except ValueError as e:
93
+ error_msg = f"{_type.title()} '{tool_call.name!r}' not exists: {e}"
94
+ console.print(error_msg, style="red")
95
+ return error_msg, False
96
+
97
+ # 2. Parse tool arguments
98
+ try:
99
+ arguments = repair_json(tool_call.arguments, return_objects=True)
100
+ if not isinstance(arguments, dict):
101
+ error_msg = f"Invalid arguments type: {arguments!r}, should be JSON object"
102
+ console.print(error_msg, style="red")
103
+ return error_msg, False
104
+ arguments = cast(dict, arguments)
105
+ except Exception as e:
106
+ error_msg = f"Invalid arguments from llm: {e}\nRaw arguments: {tool_call.arguments!r}"
107
+ console.print(error_msg, style="red")
108
+ return error_msg, False
109
+
110
+ # 3. Execute the tool
111
+ try:
112
+ result = tool.execute(**arguments)
113
+ if show_output:
114
+ panel = Panel(
115
+ result,
116
+ title=f"{_type.title()} output",
117
+ title_align="left",
118
+ expand=False,
119
+ border_style="blue",
120
+ style="dim",
121
+ )
122
+ console.print(panel)
123
+ return result, True
124
+ except Exception as e:
125
+ error_msg = f"Call {_type} error: {e}\n{_type} name: {tool_call.name!r}\nArguments: {arguments!r}"
126
+ console.print(error_msg, style="red")
127
+ return error_msg, False
@@ -0,0 +1,90 @@
1
+ import importlib.util
2
+ import sys
3
+ from typing import Callable, List, Optional
4
+
5
+ from instructor import OpenAISchema
6
+
7
+ from ..const import FUNCTIONS_DIR
8
+ from ..utils import wrap_function
9
+
10
+
11
+ class Function:
12
+ """Function description class"""
13
+
14
+ def __init__(self, function: type[OpenAISchema]):
15
+ self.name = function.openai_schema["name"]
16
+ self.description = function.openai_schema.get("description", "")
17
+ self.parameters = function.openai_schema.get("parameters", {})
18
+ self.execute = function.execute # type: ignore
19
+
20
+
21
+ _func_name_map: Optional[dict[str, Function]] = None
22
+
23
+
24
+ def get_func_name_map() -> dict[str, Function]:
25
+ """Get function name map"""
26
+ global _func_name_map
27
+ if _func_name_map:
28
+ return _func_name_map
29
+ if not FUNCTIONS_DIR.exists():
30
+ FUNCTIONS_DIR.mkdir(parents=True, exist_ok=True)
31
+ return {}
32
+ functions = []
33
+ for file in FUNCTIONS_DIR.glob("*.py"):
34
+ if file.name.startswith("_"):
35
+ continue
36
+ module_name = str(file).replace("/", ".").rstrip(".py")
37
+ spec = importlib.util.spec_from_file_location(module_name, str(file))
38
+ module = importlib.util.module_from_spec(spec) # type: ignore
39
+ sys.modules[module_name] = module
40
+ spec.loader.exec_module(module) # type: ignore
41
+
42
+ if not issubclass(module.Function, OpenAISchema):
43
+ raise TypeError(f"Function {module_name} must be a subclass of instructor.OpenAISchema")
44
+ if not hasattr(module.Function, "execute"):
45
+ raise TypeError(f"Function {module_name} must have an 'execute' classmethod")
46
+
47
+ # Add to function list
48
+ functions.append(Function(function=module.Function))
49
+
50
+ # Cache the function list
51
+ _func_name_map = {func.name: func for func in functions}
52
+ return _func_name_map
53
+
54
+
55
+ def list_functions() -> list[Function]:
56
+ """List all available buildin functions"""
57
+ global _func_name_map
58
+ if not _func_name_map:
59
+ _func_name_map = get_func_name_map()
60
+
61
+ return list(_func_name_map.values())
62
+
63
+
64
+ def get_function(name: str) -> Function:
65
+ """Get a function by name
66
+
67
+ Args:
68
+ name: Function name
69
+
70
+ Returns:
71
+ Function execute method
72
+
73
+ Raises:
74
+ ValueError: If function not found
75
+ """
76
+ func_map = get_func_name_map()
77
+ if name in func_map:
78
+ return func_map[name]
79
+ raise ValueError(f"Function {name!r} not found")
80
+
81
+
82
+ def get_functions_gemini_format() -> List[Callable]:
83
+ """Get functions in gemini format"""
84
+ gemini_functions = []
85
+ for func_name, func in get_func_name_map().items():
86
+ wrapped_func = wrap_function(func.execute)
87
+ wrapped_func.__name__ = func_name
88
+ wrapped_func.__doc__ = func.description
89
+ gemini_functions.append(wrapped_func)
90
+ return gemini_functions
yaicli/tools/mcp.py ADDED
@@ -0,0 +1,459 @@
1
+ import inspect
2
+ import json
3
+ import threading
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Any, Callable, Dict, List, Optional
7
+
8
+ from fastmcp.client import Client
9
+ from fastmcp.utilities.types import MCPContent
10
+ from mcp.types import TextContent, Tool
11
+
12
+ from ..const import MCP_JSON_PATH
13
+ from ..utils import get_or_create_event_loop
14
+
15
+ MCP_TOOL_NAME_PREFIX = "_mcp__"
16
+
17
+
18
+ def gen_mcp_tool_name(name: str) -> str:
19
+ """Generate MCP tool name
20
+ Add the prefix _mcp__ to the tool name.
21
+
22
+ <original_tool_name> ==> _mcp__<original_tool_name>
23
+
24
+ Args:
25
+ name: Original tool name
26
+ Returns:
27
+ str
28
+ """
29
+ if not name.startswith(MCP_TOOL_NAME_PREFIX):
30
+ name = f"{MCP_TOOL_NAME_PREFIX}{name}"
31
+ return name
32
+
33
+
34
+ def parse_mcp_tool_name(name: str) -> str:
35
+ """Parse MCP tool name
36
+ Remove the prefix _mcp__ from the tool name.
37
+
38
+ _mcp__<original_tool_name> ==> <original_tool_name>
39
+
40
+ Args:
41
+ name: MCP tool name
42
+ Returns:
43
+ str
44
+ """
45
+ return name.removeprefix(MCP_TOOL_NAME_PREFIX)
46
+
47
+
48
+ @dataclass
49
+ class MCPConfig:
50
+ """MCP config class"""
51
+
52
+ servers: Dict[str, Any]
53
+
54
+ @classmethod
55
+ def from_file(cls, config_path: Path) -> "MCPConfig":
56
+ """Load config from file
57
+
58
+ Args:
59
+ config_path: Path to MCP config file
60
+ Returns:
61
+ MCPConfig
62
+ Raises:
63
+ FileNotFoundError: If the MCP config file is not found
64
+ """
65
+ if not config_path.exists():
66
+ raise FileNotFoundError(f"MCP config file not found: {config_path}")
67
+
68
+ config_data = json.loads(config_path.read_text(encoding="utf-8"))
69
+
70
+ # Convert config format (type -> transport)
71
+ for server_config in config_data.get("mcpServers", {}).values():
72
+ if "type" in server_config:
73
+ server_config["transport"] = server_config.pop("type")
74
+
75
+ return cls(servers=config_data)
76
+
77
+
78
+ class MCP:
79
+ """MCP tool wrapper"""
80
+
81
+ def __init__(self, name: str, description: str, parameters: Dict[str, Any]):
82
+ self.name = gen_mcp_tool_name(name)
83
+ self.description = description
84
+ self.parameters = parameters
85
+
86
+ def execute(self, **kwargs) -> str:
87
+ """Execute tool
88
+ This function will execute the tool and return the result.
89
+ It will return the formatted result.
90
+
91
+ Args:
92
+ **kwargs: Tool parameters
93
+ Returns:
94
+ str
95
+ """
96
+ try:
97
+ client = get_mcp_manager().client
98
+ result = client.call_tool(self.name, **kwargs)
99
+ return self._format_result(result)
100
+ except Exception as e:
101
+ return f"Tool '{self.name}' execution failed: {e}"
102
+
103
+ def _format_result(self, result: List[MCPContent]) -> str:
104
+ """Format result to string
105
+ This function is used to format the result to string.
106
+ It will return the text of the first result if the result is a TextContent.
107
+ It will return the string representation of the first result if the result is not a TextContent.
108
+
109
+ Args:
110
+ result: List[MCPContent]
111
+ Returns:
112
+ str
113
+ """
114
+ if not result:
115
+ return ""
116
+
117
+ first_result = result[0]
118
+ if isinstance(first_result, TextContent):
119
+ return first_result.text
120
+ return str(first_result)
121
+
122
+ def __repr__(self) -> str:
123
+ return f"MCP(name='{self.name}', description='{self.description}', parameters={self.parameters})"
124
+
125
+
126
+ class MCPClient:
127
+ """MCP client (thread-safe singleton)"""
128
+
129
+ _instance: Optional["MCPClient"] = None
130
+ _lock = threading.Lock()
131
+
132
+ def __new__(cls, *args, **kwargs) -> "MCPClient":
133
+ """Thread-safe singleton implementation"""
134
+ if cls._instance is None:
135
+ with cls._lock:
136
+ if cls._instance is None:
137
+ cls._instance = super().__new__(cls)
138
+ cls._instance._initialized = False
139
+ return cls._instance
140
+
141
+ def __init__(self, config: Optional[MCPConfig] = None):
142
+ """Initialize MCP client
143
+
144
+ Convert async functions to sync functions.
145
+ This is a workaround to make the MCP client thread-safe.
146
+ """
147
+ if getattr(self, "_initialized", False):
148
+ return
149
+ if not config:
150
+ config = MCPConfig.from_file(MCP_JSON_PATH)
151
+
152
+ self.config = config
153
+ self._client = Client(self.config.servers)
154
+
155
+ # _tools_map: "_mcp__<original_tool_name>" -> MCP
156
+ self._tools_map: Optional[Dict[str, MCP]] = None
157
+ self._tools: Optional[List[Tool]] = None
158
+ self._initialized = True
159
+
160
+ def ping(self) -> None:
161
+ """Test connection"""
162
+ loop = get_or_create_event_loop()
163
+ loop.run_until_complete(self._ping_async())
164
+
165
+ async def _ping_async(self) -> None:
166
+ """Async ping implementation"""
167
+ async with self._client:
168
+ await self._client.ping()
169
+
170
+ def list_tools(self) -> List[Tool]:
171
+ """Get tool list
172
+ This function will list all tools from the MCP server.
173
+ Returns:
174
+ List[Tool]: Tool object list from fastmcp.types.Tool
175
+ """
176
+ if self._tools is None:
177
+ loop = get_or_create_event_loop()
178
+ self._tools = loop.run_until_complete(self._list_tools_async())
179
+ return self._tools
180
+
181
+ async def _list_tools_async(self) -> List[Tool]:
182
+ """Async get tool list"""
183
+ async with self._client:
184
+ return await self._client.list_tools()
185
+
186
+ def call_tool(self, tool_name: str, **kwargs) -> List[MCPContent]:
187
+ """Call tool"""
188
+ tool_name = parse_mcp_tool_name(tool_name)
189
+ loop = get_or_create_event_loop()
190
+ return loop.run_until_complete(self._call_tool_async(tool_name, **kwargs))
191
+
192
+ async def _call_tool_async(self, tool_name: str, **kwargs) -> List[MCPContent]:
193
+ """Async call tool"""
194
+ async with self._client:
195
+ return await self._client.call_tool(tool_name, kwargs)
196
+
197
+ @property
198
+ def tools(self) -> List[Tool]:
199
+ """Get tool list
200
+ This property will be lazy loaded.
201
+ Returns:
202
+ List[Tool]: Tool object list from fastmcp.types.Tool
203
+ """
204
+ if self._tools is None:
205
+ self._tools = self.list_tools()
206
+ return self._tools
207
+
208
+ @property
209
+ def tools_map(self) -> Dict[str, MCP]:
210
+ """Get MCP tool object mapping
211
+ key: _mcp__<original_tool_name>
212
+ value: MCP tool object
213
+ This property will be lazy loaded.
214
+ Returns:
215
+ Dict[str, MCP]: MCP tool object mapping
216
+ """
217
+ if self._tools_map is None:
218
+ self._tools_map = {}
219
+ for tool in self.tools:
220
+ self._tools_map[gen_mcp_tool_name(tool.name)] = MCP(tool.name, tool.description or "", tool.inputSchema)
221
+ return self._tools_map
222
+
223
+ def get_tool(self, name: str) -> MCP:
224
+ """Get MCP tool object
225
+
226
+ This function will ensure the tool name is prefixed with _mcp__<original_tool_name>
227
+ and raise an error if the tool name is not found.
228
+
229
+ Args:
230
+ name: _mcp__<original_tool_name>
231
+ Returns:
232
+ MCP tool object
233
+ Raises:
234
+ ValueError: If the tool name is not found
235
+ """
236
+ name = gen_mcp_tool_name(name)
237
+ if name not in self.tools_map:
238
+ available_tools = list(self.tools_map.keys())
239
+ raise ValueError(f"MCP tool '{name}' not found. Available tools: {available_tools}")
240
+ return self.tools_map[name]
241
+
242
+ def __del__(self):
243
+ """Close client"""
244
+ loop = get_or_create_event_loop()
245
+ loop.run_until_complete(self._client.close())
246
+
247
+
248
+ class MCPToolConverter:
249
+ """Tool format converter"""
250
+
251
+ def __init__(self, client: MCPClient):
252
+ self.client = client
253
+
254
+ def to_openai_format(self) -> List[Dict[str, Any]]:
255
+ """Convert to OpenAI function call format"""
256
+ openai_tools = []
257
+
258
+ for tool in self.client.tools:
259
+ openai_tool = {
260
+ "type": "function",
261
+ "function": {
262
+ "name": gen_mcp_tool_name(tool.name),
263
+ "description": tool.description or "",
264
+ "parameters": tool.inputSchema,
265
+ },
266
+ }
267
+ openai_tools.append(openai_tool)
268
+
269
+ return openai_tools
270
+
271
+ def _create_parameter_from_schema(
272
+ self, name: str, prop_info: Dict[str, Any], required: List[str]
273
+ ) -> inspect.Parameter:
274
+ """Create inspect.Parameter from JSON schema property
275
+
276
+ This function is used to create inspect.Parameter from JSON schema property.
277
+ 'array' ==> List[T]
278
+ 'enum' ==> Literal[T]
279
+ 'string' ==> str
280
+ 'integer' ==> int
281
+ 'number' ==> float | int (if default is int, it will be converted to int)
282
+ 'boolean' ==> bool
283
+ 'object' ==> dict
284
+
285
+ Args:
286
+ name: Parameter name
287
+ prop_info: Property info
288
+ required: Required parameters
289
+ Returns:
290
+ inspect.Parameter
291
+ """
292
+ # Ensure parameter type
293
+ param_type = prop_info.get("type", "string")
294
+
295
+ # Type mapping
296
+ type_mapping = {
297
+ "string": str,
298
+ "integer": int,
299
+ "number": float,
300
+ "boolean": bool,
301
+ "array": list,
302
+ "object": dict,
303
+ }
304
+
305
+ # Update annotation based on type and default value
306
+ annotation = type_mapping.get(param_type, str)
307
+ if annotation == float:
308
+ default = prop_info.get("default", None)
309
+ if default is not None:
310
+ annotation = int if isinstance(default, int) else float
311
+
312
+ # Handle array type
313
+ if param_type == "array" and "items" in prop_info:
314
+ item_type = prop_info["items"].get("type", "string")
315
+ item_annotation = type_mapping.get(item_type, str)
316
+ annotation = List[item_annotation]
317
+
318
+ # Handle enum type
319
+ if "enum" in prop_info:
320
+ from typing import Literal
321
+
322
+ annotation = Literal[tuple(prop_info["enum"])] # type: ignore
323
+
324
+ # Handle optional parameter
325
+ if name not in required:
326
+ from typing import Optional
327
+
328
+ annotation = Optional[annotation]
329
+
330
+ # Ensure default value
331
+ if name in required:
332
+ default = inspect.Parameter.empty
333
+ else:
334
+ default = prop_info.get("default", None)
335
+
336
+ return inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD, default=default, annotation=annotation)
337
+
338
+ def _create_dynamic_function(self, tool_obj: MCP) -> Callable:
339
+ """Create dynamic function with proper signature and type annotations
340
+
341
+ This function is used to create a dynamic function with proper signature and type annotations.
342
+ It will create a dynamic function that can be used as a tool in the LLM.
343
+ Callable.__signature__ = inspect.Signature(parameters=inspect.Parameter(name, inspect.Parameter.POSITIONAL_OR_KEYWORD, default=default, annotation=annotation))
344
+ Callable.__name__ = _mcp__<original_tool_name>
345
+ Callable.__doc__ = tool_obj.description
346
+ Callable.__annotations__ = {param.name: param.annotation for param in params}
347
+ Callable.__annotations__["return"] = str # MCP tools return string
348
+
349
+ Args:
350
+ tool_obj: MCP tool object
351
+ Returns:
352
+ Callable
353
+ """
354
+ properties = tool_obj.parameters.get("properties", {})
355
+ required = tool_obj.parameters.get("required", [])
356
+
357
+ # Create parameter list
358
+ params = [
359
+ self._create_parameter_from_schema(name, prop_info, required) for name, prop_info in properties.items()
360
+ ]
361
+
362
+ # Dynamic function
363
+ def dynamic_function(**kwargs):
364
+ return tool_obj.execute(**kwargs)
365
+
366
+ # Set function attributes
367
+ dynamic_function.__signature__ = inspect.Signature(parameters=params)
368
+ dynamic_function.__name__ = gen_mcp_tool_name(tool_obj.name)
369
+ dynamic_function.__doc__ = tool_obj.description
370
+
371
+ # Set type annotations (simulate get_type_hints result)
372
+ annotations = {param.name: param.annotation for param in params}
373
+ annotations["return"] = str # MCP tools return string
374
+ dynamic_function.__annotations__ = annotations
375
+
376
+ return dynamic_function
377
+
378
+ def to_gemini_format(self) -> List[Callable]:
379
+ """Convert to Gemini function call format
380
+ Gemini automatic function calling parses the function signature and type annotations to generate the function declaration.
381
+ So we need to create a dynamic function with proper signature and type annotations.
382
+ """
383
+ return [self._create_dynamic_function(tool) for tool in self.client.tools_map.values()]
384
+
385
+
386
+ class MCPManager:
387
+ """MCP manager - provide unified API interface"""
388
+
389
+ def __init__(self, config_path: Optional[Path] = None):
390
+ self.config_path = config_path or MCP_JSON_PATH
391
+ self._client: Optional[MCPClient] = None
392
+ self._converter: Optional[MCPToolConverter] = None
393
+
394
+ @property
395
+ def client(self) -> MCPClient:
396
+ """Lazy load client"""
397
+ if self._client is None:
398
+ config = MCPConfig.from_file(self.config_path)
399
+ self._client = MCPClient(config)
400
+ return self._client
401
+
402
+ @property
403
+ def converter(self) -> MCPToolConverter:
404
+ """Lazy load converter"""
405
+ if self._converter is None:
406
+ self._converter = MCPToolConverter(self.client)
407
+ return self._converter
408
+
409
+ def ping(self) -> None:
410
+ """Test connection"""
411
+ self.client.ping()
412
+
413
+ def list_tools(self) -> List[Tool]:
414
+ """Get tool name list"""
415
+ return self.client.tools
416
+
417
+ def get_tool(self, name: str) -> MCP:
418
+ """Get tool"""
419
+ # Verify tool exists
420
+ name = gen_mcp_tool_name(name)
421
+ return self.client.get_tool(name)
422
+
423
+ def execute_tool(self, name: str, **kwargs) -> str:
424
+ """Execute tool"""
425
+ tool = self.get_tool(name)
426
+ return tool.execute(**kwargs)
427
+
428
+ def to_openai_tools(self) -> List[Dict[str, Any]]:
429
+ """Convert to OpenAI tool format"""
430
+ return self.converter.to_openai_format()
431
+
432
+ def to_gemini_tools(self) -> List[Callable]:
433
+ """Convert to Gemini tool format"""
434
+ return self.converter.to_gemini_format()
435
+
436
+
437
+ # Global instance
438
+ _mcp_manager: Optional[MCPManager] = None
439
+
440
+
441
+ def get_mcp_manager(config_path: Optional[Path] = None) -> MCPManager:
442
+ """Get MCP manager instance
443
+
444
+ Args:
445
+ config_path: Path to MCP config file
446
+ Returns:
447
+ MCPManager
448
+ Raises:
449
+ FileNotFoundError: If the MCP config file is not found
450
+ """
451
+ global _mcp_manager
452
+ if _mcp_manager is None:
453
+ _mcp_manager = MCPManager(config_path)
454
+ return _mcp_manager
455
+
456
+
457
+ def get_mcp(name: str) -> MCP:
458
+ """Get MCP tool - compatible with original API"""
459
+ return get_mcp_manager().get_tool(name)