universal-mcp 0.1.21rc2__py3-none-any.whl → 0.1.22rc4__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.
- universal_mcp/applications/__init__.py +5 -1
- universal_mcp/integrations/integration.py +4 -8
- universal_mcp/servers/server.py +15 -31
- universal_mcp/tools/adapters.py +39 -3
- universal_mcp/tools/manager.py +122 -37
- universal_mcp/tools/tools.py +1 -1
- universal_mcp/utils/agentr.py +27 -13
- universal_mcp/utils/docstring_parser.py +18 -64
- universal_mcp/utils/openapi/api_splitter.py +250 -132
- universal_mcp/utils/openapi/openapi.py +137 -118
- universal_mcp/utils/openapi/preprocessor.py +272 -29
- universal_mcp/utils/testing.py +31 -0
- {universal_mcp-0.1.21rc2.dist-info → universal_mcp-0.1.22rc4.dist-info}/METADATA +2 -1
- {universal_mcp-0.1.21rc2.dist-info → universal_mcp-0.1.22rc4.dist-info}/RECORD +17 -16
- {universal_mcp-0.1.21rc2.dist-info → universal_mcp-0.1.22rc4.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.21rc2.dist-info → universal_mcp-0.1.22rc4.dist-info}/entry_points.txt +0 -0
- {universal_mcp-0.1.21rc2.dist-info → universal_mcp-0.1.22rc4.dist-info}/licenses/LICENSE +0 -0
@@ -30,12 +30,13 @@ sys.path.append(str(UNIVERSAL_MCP_HOME))
|
|
30
30
|
# Name are in the format of "app-name", eg, google-calendar
|
31
31
|
# Class name is NameApp, eg, GoogleCalendarApp
|
32
32
|
|
33
|
+
app_cache: dict[str, type[BaseApplication]] = {}
|
34
|
+
|
33
35
|
|
34
36
|
def _install_or_upgrade_package(package_name: str, repository_path: str):
|
35
37
|
"""
|
36
38
|
Helper to install a package via pip from the universal-mcp GitHub repository.
|
37
39
|
"""
|
38
|
-
|
39
40
|
uv_path = os.getenv("UV_PATH")
|
40
41
|
uv_executable = str(Path(uv_path) / "uv") if uv_path else "uv"
|
41
42
|
logger.info(f"Using uv executable: {uv_executable}")
|
@@ -72,6 +73,8 @@ def app_from_slug(slug: str):
|
|
72
73
|
Dynamically resolve and return the application class for the given slug.
|
73
74
|
Attempts installation from GitHub if the package is not found locally.
|
74
75
|
"""
|
76
|
+
if slug in app_cache:
|
77
|
+
return app_cache[slug]
|
75
78
|
class_name = get_default_class_name(slug)
|
76
79
|
module_path = get_default_module_path(slug)
|
77
80
|
package_name = get_default_package_name(slug)
|
@@ -82,6 +85,7 @@ def app_from_slug(slug: str):
|
|
82
85
|
module = importlib.import_module(module_path)
|
83
86
|
class_ = getattr(module, class_name)
|
84
87
|
logger.debug(f"Loaded class '{class_}' from module '{module_path}'")
|
88
|
+
app_cache[slug] = class_
|
85
89
|
return class_
|
86
90
|
except ModuleNotFoundError as e:
|
87
91
|
raise ModuleNotFoundError(f"Package '{module_path}' not found locally. Please install it first.") from e
|
@@ -3,9 +3,8 @@ from typing import Any
|
|
3
3
|
import httpx
|
4
4
|
from loguru import logger
|
5
5
|
|
6
|
-
from universal_mcp.exceptions import NotAuthorizedError
|
7
|
-
from universal_mcp.stores import BaseStore
|
8
|
-
from universal_mcp.stores.store import KeyNotFoundError, MemoryStore
|
6
|
+
from universal_mcp.exceptions import KeyNotFoundError, NotAuthorizedError
|
7
|
+
from universal_mcp.stores import BaseStore, MemoryStore
|
9
8
|
from universal_mcp.utils.agentr import AgentrClient
|
10
9
|
|
11
10
|
|
@@ -34,10 +33,7 @@ class Integration:
|
|
34
33
|
|
35
34
|
def __init__(self, name: str, store: BaseStore | None = None):
|
36
35
|
self.name = name
|
37
|
-
|
38
|
-
self.store = MemoryStore()
|
39
|
-
else:
|
40
|
-
self.store = store
|
36
|
+
self.store = store or MemoryStore()
|
41
37
|
|
42
38
|
def authorize(self) -> str | dict[str, Any]:
|
43
39
|
"""Authorize the integration.
|
@@ -329,7 +325,7 @@ class AgentRIntegration(Integration):
|
|
329
325
|
ValueError: If no API key is provided or found in environment variables
|
330
326
|
"""
|
331
327
|
|
332
|
-
def __init__(self, name: str, api_key: str
|
328
|
+
def __init__(self, name: str, api_key: str, **kwargs):
|
333
329
|
super().__init__(name, **kwargs)
|
334
330
|
self.client = AgentrClient(api_key=api_key)
|
335
331
|
self._credentials = None
|
universal_mcp/servers/server.py
CHANGED
@@ -13,7 +13,7 @@ from universal_mcp.exceptions import ConfigurationError, ToolError
|
|
13
13
|
from universal_mcp.integrations import AgentRIntegration, integration_from_config
|
14
14
|
from universal_mcp.stores import BaseStore, store_from_config
|
15
15
|
from universal_mcp.tools import ToolManager
|
16
|
-
from universal_mcp.tools.adapters import ToolFormat
|
16
|
+
from universal_mcp.tools.adapters import ToolFormat, format_to_mcp_result
|
17
17
|
from universal_mcp.utils.agentr import AgentrClient
|
18
18
|
|
19
19
|
|
@@ -34,6 +34,7 @@ class BaseServer(FastMCP):
|
|
34
34
|
logger.info(f"Initializing server: {config.name} ({config.type}) with store: {config.store}")
|
35
35
|
self.config = config
|
36
36
|
self._tool_manager = tool_manager or ToolManager(warn_on_duplicate_tools=True)
|
37
|
+
# Validate config after setting attributes to ensure proper initialization
|
37
38
|
ServerConfig.model_validate(config)
|
38
39
|
except Exception as e:
|
39
40
|
logger.error(f"Failed to initialize server: {e}", exc_info=True)
|
@@ -48,10 +49,9 @@ class BaseServer(FastMCP):
|
|
48
49
|
Raises:
|
49
50
|
ValueError: If tool is invalid
|
50
51
|
"""
|
51
|
-
|
52
52
|
self._tool_manager.add_tool(tool)
|
53
53
|
|
54
|
-
async def list_tools(self) -> list
|
54
|
+
async def list_tools(self) -> list:
|
55
55
|
"""List all available tools in MCP format.
|
56
56
|
|
57
57
|
Returns:
|
@@ -59,23 +59,6 @@ class BaseServer(FastMCP):
|
|
59
59
|
"""
|
60
60
|
return self._tool_manager.list_tools(format=ToolFormat.MCP)
|
61
61
|
|
62
|
-
def _format_tool_result(self, result: Any) -> list[TextContent]:
|
63
|
-
"""Format tool result into TextContent list.
|
64
|
-
|
65
|
-
Args:
|
66
|
-
result: Raw tool result
|
67
|
-
|
68
|
-
Returns:
|
69
|
-
List of TextContent objects
|
70
|
-
"""
|
71
|
-
if isinstance(result, str):
|
72
|
-
return [TextContent(type="text", text=result)]
|
73
|
-
elif isinstance(result, list) and all(isinstance(item, TextContent) for item in result):
|
74
|
-
return result
|
75
|
-
else:
|
76
|
-
logger.warning(f"Tool returned unexpected type: {type(result)}. Wrapping in TextContent.")
|
77
|
-
return [TextContent(type="text", text=str(result))]
|
78
|
-
|
79
62
|
async def call_tool(self, name: str, arguments: dict[str, Any]) -> list[TextContent]:
|
80
63
|
"""Call a tool with comprehensive error handling.
|
81
64
|
|
@@ -99,7 +82,7 @@ class BaseServer(FastMCP):
|
|
99
82
|
try:
|
100
83
|
result = await self._tool_manager.call_tool(name, arguments)
|
101
84
|
logger.info(f"Tool '{name}' completed successfully")
|
102
|
-
return
|
85
|
+
return format_to_mcp_result(result)
|
103
86
|
except Exception as e:
|
104
87
|
logger.error(f"Tool '{name}' failed: {e}", exc_info=True)
|
105
88
|
raise ToolError(f"Tool execution failed: {str(e)}") from e
|
@@ -219,11 +202,12 @@ class AgentRServer(BaseServer):
|
|
219
202
|
"""
|
220
203
|
|
221
204
|
def __init__(self, config: ServerConfig, **kwargs):
|
205
|
+
super().__init__(config, **kwargs)
|
222
206
|
self.api_key = config.api_key.get_secret_value() if config.api_key else None
|
207
|
+
if not self.api_key:
|
208
|
+
raise ValueError("API key is required for AgentR server")
|
223
209
|
logger.info(f"Initializing AgentR server with API key: {self.api_key}")
|
224
210
|
self.client = AgentrClient(api_key=self.api_key)
|
225
|
-
super().__init__(config, **kwargs)
|
226
|
-
self.integration = AgentRIntegration(name="agentr", api_key=self.client.api_key)
|
227
211
|
self._load_apps()
|
228
212
|
|
229
213
|
def _fetch_apps(self) -> list[AppConfig]:
|
@@ -261,7 +245,7 @@ class AgentRServer(BaseServer):
|
|
261
245
|
"""
|
262
246
|
try:
|
263
247
|
integration = (
|
264
|
-
AgentRIntegration(name=app_config.integration.name, api_key=self.
|
248
|
+
AgentRIntegration(name=app_config.integration.name, api_key=self.api_key)
|
265
249
|
if app_config.integration
|
266
250
|
else None
|
267
251
|
)
|
@@ -322,12 +306,12 @@ class SingleMCPServer(BaseServer):
|
|
322
306
|
**kwargs,
|
323
307
|
):
|
324
308
|
if not app_instance:
|
325
|
-
raise ValueError("app_instance is required")
|
326
|
-
|
327
|
-
|
328
|
-
|
329
|
-
|
330
|
-
|
331
|
-
|
309
|
+
raise ValueError("app_instance is required for SingleMCPServer")
|
310
|
+
|
311
|
+
config = config or ServerConfig(
|
312
|
+
type="local",
|
313
|
+
name=f"{app_instance.name.title()} MCP Server for Local Development",
|
314
|
+
description=f"Minimal MCP server for the local {app_instance.name} application.",
|
315
|
+
)
|
332
316
|
super().__init__(config, **kwargs)
|
333
317
|
self._tool_manager.register_tools_from_app(app_instance, tags="all")
|
universal_mcp/tools/adapters.py
CHANGED
@@ -1,5 +1,8 @@
|
|
1
1
|
from enum import Enum
|
2
2
|
|
3
|
+
from loguru import logger
|
4
|
+
from mcp.types import TextContent
|
5
|
+
|
3
6
|
from universal_mcp.tools.tools import Tool
|
4
7
|
|
5
8
|
|
@@ -16,11 +19,35 @@ def convert_tool_to_mcp_tool(
|
|
16
19
|
):
|
17
20
|
from mcp.server.fastmcp.server import MCPTool
|
18
21
|
|
19
|
-
|
22
|
+
logger.debug(f"Converting tool '{tool.name}' to MCP format")
|
23
|
+
mcp_tool = MCPTool(
|
20
24
|
name=tool.name[:63],
|
21
25
|
description=tool.description or "",
|
22
26
|
inputSchema=tool.parameters,
|
23
27
|
)
|
28
|
+
logger.debug(f"Successfully converted tool '{tool.name}' to MCP format")
|
29
|
+
return mcp_tool
|
30
|
+
|
31
|
+
|
32
|
+
def format_to_mcp_result(result: any) -> list[TextContent]:
|
33
|
+
"""Format tool result into TextContent list.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
result: Raw tool result
|
37
|
+
|
38
|
+
Returns:
|
39
|
+
List of TextContent objects
|
40
|
+
"""
|
41
|
+
logger.debug(f"Formatting result to MCP format, type: {type(result)}")
|
42
|
+
if isinstance(result, str):
|
43
|
+
logger.debug("Result is string, wrapping in TextContent")
|
44
|
+
return [TextContent(type="text", text=result)]
|
45
|
+
elif isinstance(result, list) and all(isinstance(item, TextContent) for item in result):
|
46
|
+
logger.debug("Result is already list of TextContent objects")
|
47
|
+
return result
|
48
|
+
else:
|
49
|
+
logger.warning(f"Tool returned unexpected type: {type(result)}. Wrapping in TextContent.")
|
50
|
+
return [TextContent(type="text", text=str(result))]
|
24
51
|
|
25
52
|
|
26
53
|
def convert_tool_to_langchain_tool(
|
@@ -39,26 +66,33 @@ def convert_tool_to_langchain_tool(
|
|
39
66
|
a LangChain tool
|
40
67
|
"""
|
41
68
|
|
69
|
+
logger.debug(f"Converting tool '{tool.name}' to LangChain format")
|
70
|
+
|
42
71
|
async def call_tool(
|
43
72
|
**arguments: dict[str, any],
|
44
73
|
):
|
74
|
+
logger.debug(f"Executing LangChain tool '{tool.name}' with arguments: {arguments}")
|
45
75
|
call_tool_result = await tool.run(arguments)
|
76
|
+
logger.debug(f"Tool '{tool.name}' execution completed")
|
46
77
|
return call_tool_result
|
47
78
|
|
48
|
-
|
79
|
+
langchain_tool = StructuredTool(
|
49
80
|
name=tool.name,
|
50
81
|
description=tool.description or "",
|
51
82
|
coroutine=call_tool,
|
52
83
|
response_format="content",
|
53
84
|
args_schema=tool.parameters,
|
54
85
|
)
|
86
|
+
logger.debug(f"Successfully converted tool '{tool.name}' to LangChain format")
|
87
|
+
return langchain_tool
|
55
88
|
|
56
89
|
|
57
90
|
def convert_tool_to_openai_tool(
|
58
91
|
tool: Tool,
|
59
92
|
):
|
60
93
|
"""Convert a Tool object to an OpenAI function."""
|
61
|
-
|
94
|
+
logger.debug(f"Converting tool '{tool.name}' to OpenAI format")
|
95
|
+
openai_tool = {
|
62
96
|
"type": "function",
|
63
97
|
"function": {
|
64
98
|
"name": tool.name,
|
@@ -66,3 +100,5 @@ def convert_tool_to_openai_tool(
|
|
66
100
|
"parameters": tool.parameters,
|
67
101
|
},
|
68
102
|
}
|
103
|
+
logger.debug(f"Successfully converted tool '{tool.name}' to OpenAI format")
|
104
|
+
return openai_tool
|
universal_mcp/tools/manager.py
CHANGED
@@ -1,4 +1,3 @@
|
|
1
|
-
import re
|
2
1
|
from collections.abc import Callable
|
3
2
|
from typing import Any
|
4
3
|
|
@@ -18,28 +17,67 @@ from universal_mcp.tools.tools import Tool
|
|
18
17
|
# Constants
|
19
18
|
DEFAULT_IMPORTANT_TAG = "important"
|
20
19
|
TOOL_NAME_SEPARATOR = "_"
|
20
|
+
DEFAULT_APP_NAME = "common"
|
21
21
|
|
22
22
|
|
23
23
|
def _filter_by_name(tools: list[Tool], tool_names: list[str] | None) -> list[Tool]:
|
24
|
+
"""Filter tools by name using simple string matching.
|
25
|
+
|
26
|
+
Args:
|
27
|
+
tools: List of tools to filter.
|
28
|
+
tool_names: List of tool names to match against.
|
29
|
+
|
30
|
+
Returns:
|
31
|
+
Filtered list of tools.
|
32
|
+
"""
|
24
33
|
if not tool_names:
|
25
34
|
return tools
|
26
|
-
|
35
|
+
|
36
|
+
logger.debug(f"Filtering tools by names: {tool_names}")
|
37
|
+
# Convert names to lowercase for case-insensitive matching
|
38
|
+
tool_names = [name.lower() for name in tool_names]
|
27
39
|
filtered_tools = []
|
28
40
|
for tool in tools:
|
29
|
-
for
|
30
|
-
if
|
41
|
+
for tool_name in tool_names:
|
42
|
+
if tool_name in tool.name.lower():
|
31
43
|
filtered_tools.append(tool)
|
44
|
+
logger.debug(f"Tool '{tool.name}' matched name filter")
|
45
|
+
break
|
46
|
+
|
32
47
|
return filtered_tools
|
33
48
|
|
34
49
|
|
35
50
|
def _filter_by_tags(tools: list[Tool], tags: list[str] | None) -> list[Tool]:
|
51
|
+
"""Filter tools by tags with improved matching logic.
|
52
|
+
|
53
|
+
Args:
|
54
|
+
tools: List of tools to filter.
|
55
|
+
tags: List of tags to match against.
|
56
|
+
|
57
|
+
Returns:
|
58
|
+
Filtered list of tools.
|
59
|
+
"""
|
60
|
+
if not tags:
|
61
|
+
return tools
|
62
|
+
|
36
63
|
logger.debug(f"Filtering tools by tags: {tags}")
|
64
|
+
|
65
|
+
# Handle special "all" tag
|
37
66
|
if "all" in tags:
|
38
67
|
return tools
|
39
68
|
|
40
|
-
|
41
|
-
|
42
|
-
|
69
|
+
# Convert tags to lowercase for case-insensitive matching
|
70
|
+
tags_set = {tag.lower() for tag in tags}
|
71
|
+
|
72
|
+
filtered_tools = []
|
73
|
+
for tool in tools:
|
74
|
+
# Convert tool tags to lowercase for case-insensitive matching
|
75
|
+
tool_tags = {tag.lower() for tag in tool.tags}
|
76
|
+
if tags_set & tool_tags: # Check for any matching tags
|
77
|
+
filtered_tools.append(tool)
|
78
|
+
logger.debug(f"Tool '{tool.name}' matched tags: {tags_set & tool_tags}")
|
79
|
+
|
80
|
+
return filtered_tools
|
43
81
|
|
44
82
|
|
45
83
|
class ToolManager:
|
@@ -47,6 +85,7 @@ class ToolManager:
|
|
47
85
|
|
48
86
|
This class provides functionality for registering, managing, and executing tools.
|
49
87
|
It supports multiple tool formats and provides filtering capabilities based on names and tags.
|
88
|
+
Tools are organized by their source application for better management.
|
50
89
|
"""
|
51
90
|
|
52
91
|
def __init__(self, warn_on_duplicate_tools: bool = True):
|
@@ -55,7 +94,8 @@ class ToolManager:
|
|
55
94
|
Args:
|
56
95
|
warn_on_duplicate_tools: Whether to warn when duplicate tool names are detected.
|
57
96
|
"""
|
58
|
-
self.
|
97
|
+
self._tools_by_app: dict[str, dict[str, Tool]] = {}
|
98
|
+
self._all_tools: dict[str, Tool] = {}
|
59
99
|
self.warn_on_duplicate_tools = warn_on_duplicate_tools
|
60
100
|
|
61
101
|
def get_tool(self, name: str) -> Tool | None:
|
@@ -67,17 +107,36 @@ class ToolManager:
|
|
67
107
|
Returns:
|
68
108
|
The Tool instance if found, None otherwise.
|
69
109
|
"""
|
70
|
-
return self.
|
110
|
+
return self._all_tools.get(name)
|
111
|
+
|
112
|
+
def get_tools_by_app(self, app_name: str | None = None) -> list[Tool]:
|
113
|
+
"""Get all tools from a specific application.
|
114
|
+
|
115
|
+
Args:
|
116
|
+
app_name: The name of the application to get tools from.
|
117
|
+
|
118
|
+
Returns:
|
119
|
+
List of tools from the specified application.
|
120
|
+
"""
|
121
|
+
if app_name:
|
122
|
+
return list(self._tools_by_app.get(app_name, {}).values())
|
123
|
+
else:
|
124
|
+
return list(self._all_tools.values())
|
71
125
|
|
72
126
|
def list_tools(
|
73
127
|
self,
|
74
128
|
format: ToolFormat = ToolFormat.MCP,
|
75
129
|
tags: list[str] | None = None,
|
76
|
-
|
130
|
+
app_name: str | None = None,
|
131
|
+
tool_names: list[str] | None = None,
|
132
|
+
) -> list:
|
77
133
|
"""List all registered tools in the specified format.
|
78
134
|
|
79
135
|
Args:
|
80
136
|
format: The format to convert tools to.
|
137
|
+
tags: Optional list of tags to filter tools by.
|
138
|
+
app_name: Optional app name to filter tools by.
|
139
|
+
tool_names: Optional list of tool names to filter by.
|
81
140
|
|
82
141
|
Returns:
|
83
142
|
List of tools in the specified format.
|
@@ -85,26 +144,31 @@ class ToolManager:
|
|
85
144
|
Raises:
|
86
145
|
ValueError: If an invalid format is provided.
|
87
146
|
"""
|
147
|
+
# Start with app-specific tools or all tools
|
148
|
+
tools = self.get_tools_by_app(app_name)
|
149
|
+
# Apply filters
|
150
|
+
tools = _filter_by_tags(tools, tags)
|
151
|
+
tools = _filter_by_name(tools, tool_names)
|
88
152
|
|
89
|
-
|
90
|
-
if tags:
|
91
|
-
tools = _filter_by_tags(tools, tags)
|
153
|
+
# Convert to requested format
|
92
154
|
if format == ToolFormat.MCP:
|
93
|
-
|
155
|
+
return [convert_tool_to_mcp_tool(tool) for tool in tools]
|
94
156
|
elif format == ToolFormat.LANGCHAIN:
|
95
|
-
|
157
|
+
return [convert_tool_to_langchain_tool(tool) for tool in tools]
|
96
158
|
elif format == ToolFormat.OPENAI:
|
97
|
-
|
159
|
+
return [convert_tool_to_openai_tool(tool) for tool in tools]
|
98
160
|
else:
|
99
161
|
raise ValueError(f"Invalid format: {format}")
|
100
|
-
return tools
|
101
162
|
|
102
|
-
def add_tool(
|
163
|
+
def add_tool(
|
164
|
+
self, fn: Callable[..., Any] | Tool, name: str | None = None, app_name: str = DEFAULT_APP_NAME
|
165
|
+
) -> Tool:
|
103
166
|
"""Add a tool to the manager.
|
104
167
|
|
105
168
|
Args:
|
106
169
|
fn: The tool function or Tool instance to add.
|
107
170
|
name: Optional name override for the tool.
|
171
|
+
app_name: Application name to group the tool under.
|
108
172
|
|
109
173
|
Returns:
|
110
174
|
The registered Tool instance.
|
@@ -114,10 +178,7 @@ class ToolManager:
|
|
114
178
|
"""
|
115
179
|
tool = fn if isinstance(fn, Tool) else Tool.from_function(fn, name=name)
|
116
180
|
|
117
|
-
|
118
|
-
raise ValueError("Tool name must be a non-empty string")
|
119
|
-
|
120
|
-
existing = self._tools.get(tool.name)
|
181
|
+
existing = self._all_tools.get(tool.name)
|
121
182
|
if existing:
|
122
183
|
if self.warn_on_duplicate_tools:
|
123
184
|
if existing.fn is not tool.fn:
|
@@ -128,14 +189,25 @@ class ToolManager:
|
|
128
189
|
logger.debug(f"Tool '{tool.name}' with the same function already exists.")
|
129
190
|
return existing
|
130
191
|
|
131
|
-
logger.debug(f"Adding tool: {tool.name}")
|
132
|
-
self.
|
192
|
+
logger.debug(f"Adding tool: {tool.name} to app: {app_name}")
|
193
|
+
self._all_tools[tool.name] = tool
|
194
|
+
|
195
|
+
# Group tool by application
|
196
|
+
if app_name not in self._tools_by_app:
|
197
|
+
self._tools_by_app[app_name] = {}
|
198
|
+
self._tools_by_app[app_name][tool.name] = tool
|
199
|
+
|
133
200
|
return tool
|
134
201
|
|
135
|
-
def register_tools(self, tools: list[Tool]) -> None:
|
136
|
-
"""Register a list of tools.
|
202
|
+
def register_tools(self, tools: list[Tool], app_name: str = DEFAULT_APP_NAME) -> None:
|
203
|
+
"""Register a list of tools.
|
204
|
+
|
205
|
+
Args:
|
206
|
+
tools: List of tools to register.
|
207
|
+
app_name: Application name to group the tools under.
|
208
|
+
"""
|
137
209
|
for tool in tools:
|
138
|
-
self.add_tool(tool)
|
210
|
+
self.add_tool(tool, app_name=app_name)
|
139
211
|
|
140
212
|
def remove_tool(self, name: str) -> bool:
|
141
213
|
"""Remove a tool by name.
|
@@ -146,14 +218,24 @@ class ToolManager:
|
|
146
218
|
Returns:
|
147
219
|
True if the tool was removed, False if it didn't exist.
|
148
220
|
"""
|
149
|
-
if name in self.
|
150
|
-
|
221
|
+
if name in self._all_tools:
|
222
|
+
self._all_tools[name]
|
223
|
+
del self._all_tools[name]
|
224
|
+
|
225
|
+
# Remove from app-specific grouping if present
|
226
|
+
for app_tools in self._tools_by_app.values():
|
227
|
+
if name in app_tools:
|
228
|
+
del app_tools[name]
|
229
|
+
# PERFORMANCE: Break after finding and removing to avoid unnecessary iterations
|
230
|
+
break
|
231
|
+
|
151
232
|
return True
|
152
233
|
return False
|
153
234
|
|
154
235
|
def clear_tools(self) -> None:
|
155
236
|
"""Remove all registered tools."""
|
156
|
-
self.
|
237
|
+
self._all_tools.clear()
|
238
|
+
self._tools_by_app.clear()
|
157
239
|
|
158
240
|
def register_tools_from_app(
|
159
241
|
self,
|
@@ -190,22 +272,27 @@ class ToolManager:
|
|
190
272
|
try:
|
191
273
|
tool_instance = Tool.from_function(function)
|
192
274
|
tool_instance.name = f"{app.name}{TOOL_NAME_SEPARATOR}{tool_instance.name}"
|
193
|
-
|
275
|
+
# BUG FIX: Avoid duplicate tags - check if app.name is already in tags before adding
|
276
|
+
if app.name not in tool_instance.tags:
|
277
|
+
tool_instance.tags.append(app.name)
|
194
278
|
tools.append(tool_instance)
|
195
279
|
except Exception as e:
|
196
280
|
tool_name = getattr(function, "__name__", "unknown")
|
197
281
|
logger.error(f"Failed to create Tool from '{tool_name}' in {app.name}: {e}")
|
198
282
|
|
283
|
+
# BUG FIX: Apply filtering logic correctly - if both tool_names and tags are provided,
|
284
|
+
# we should filter by both, not use default important tag
|
199
285
|
if tags:
|
200
286
|
tools = _filter_by_tags(tools, tags)
|
201
287
|
|
202
288
|
if tool_names:
|
203
289
|
tools = _filter_by_name(tools, tool_names)
|
204
|
-
|
290
|
+
|
291
|
+
# BUG FIX: Only use default important tag if NO filters are provided at all
|
205
292
|
if not tool_names and not tags:
|
206
293
|
tools = _filter_by_tags(tools, [DEFAULT_IMPORTANT_TAG])
|
207
|
-
|
208
|
-
|
294
|
+
|
295
|
+
self.register_tools(tools, app_name=app.name)
|
209
296
|
|
210
297
|
async def call_tool(
|
211
298
|
self,
|
@@ -227,16 +314,14 @@ class ToolManager:
|
|
227
314
|
ToolError: If the tool is not found or execution fails.
|
228
315
|
"""
|
229
316
|
logger.debug(f"Calling tool: {name} with arguments: {arguments}")
|
317
|
+
app_name = name.split(TOOL_NAME_SEPARATOR, 1)[0] if TOOL_NAME_SEPARATOR in name else DEFAULT_APP_NAME
|
230
318
|
tool = self.get_tool(name)
|
231
319
|
if not tool:
|
232
320
|
raise ToolError(f"Unknown tool: {name}")
|
233
|
-
|
234
321
|
try:
|
235
322
|
result = await tool.run(arguments, context)
|
236
|
-
app_name = tool.name.split(TOOL_NAME_SEPARATOR)[0]
|
237
323
|
analytics.track_tool_called(name, app_name, "success")
|
238
324
|
return result
|
239
325
|
except Exception as e:
|
240
|
-
app_name = tool.name.split(TOOL_NAME_SEPARATOR)[0]
|
241
326
|
analytics.track_tool_called(name, app_name, "error", str(e))
|
242
327
|
raise ToolError(f"Tool execution failed: {str(e)}") from e
|
universal_mcp/tools/tools.py
CHANGED
universal_mcp/utils/agentr.py
CHANGED
@@ -1,5 +1,3 @@
|
|
1
|
-
import os
|
2
|
-
|
3
1
|
import httpx
|
4
2
|
from loguru import logger
|
5
3
|
|
@@ -18,17 +16,9 @@ class AgentrClient:
|
|
18
16
|
base_url (str, optional): Base URL for AgentR API. Defaults to https://api.agentr.dev
|
19
17
|
"""
|
20
18
|
|
21
|
-
def __init__(self, api_key: str
|
22
|
-
|
23
|
-
|
24
|
-
elif os.getenv("AGENTR_API_KEY"):
|
25
|
-
self.api_key = os.getenv("AGENTR_API_KEY")
|
26
|
-
else:
|
27
|
-
logger.error(
|
28
|
-
"API key for AgentR is missing. Please visit https://agentr.dev to create an API key, then set it as AGENTR_API_KEY environment variable."
|
29
|
-
)
|
30
|
-
raise ValueError("AgentR API key required - get one at https://agentr.dev")
|
31
|
-
self.base_url = (base_url or os.getenv("AGENTR_BASE_URL", "https://api.agentr.dev")).rstrip("/")
|
19
|
+
def __init__(self, api_key: str, base_url: str = "https://api.agentr.dev"):
|
20
|
+
self.base_url = base_url.rstrip("/")
|
21
|
+
self.api_key = api_key
|
32
22
|
self.client = httpx.Client(
|
33
23
|
base_url=self.base_url, headers={"X-API-KEY": self.api_key}, timeout=30, follow_redirects=True
|
34
24
|
)
|
@@ -88,3 +78,27 @@ class AgentrClient:
|
|
88
78
|
response.raise_for_status()
|
89
79
|
data = response.json()
|
90
80
|
return [AppConfig.model_validate(app) for app in data]
|
81
|
+
|
82
|
+
def list_all_apps(self) -> list:
|
83
|
+
"""List all apps from AgentR API.
|
84
|
+
|
85
|
+
Returns:
|
86
|
+
List of app names
|
87
|
+
"""
|
88
|
+
response = self.client.get("/apps/")
|
89
|
+
response.raise_for_status()
|
90
|
+
return response.json()
|
91
|
+
|
92
|
+
def list_actions(self, app_name: str):
|
93
|
+
"""List actions for an app.
|
94
|
+
|
95
|
+
Args:
|
96
|
+
app_name (str): Name of the app to list actions for
|
97
|
+
|
98
|
+
Returns:
|
99
|
+
List of action configurations
|
100
|
+
"""
|
101
|
+
|
102
|
+
response = self.client.get(f"/apps/{app_name}/actions/")
|
103
|
+
response.raise_for_status()
|
104
|
+
return response.json()
|