universal-mcp 0.1.21rc2__py3-none-any.whl → 0.1.22rc1__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.
@@ -35,7 +35,6 @@ def _install_or_upgrade_package(package_name: str, repository_path: str):
35
35
  """
36
36
  Helper to install a package via pip from the universal-mcp GitHub repository.
37
37
  """
38
-
39
38
  uv_path = os.getenv("UV_PATH")
40
39
  uv_executable = str(Path(uv_path) / "uv") if uv_path else "uv"
41
40
  logger.info(f"Using uv executable: {uv_executable}")
@@ -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
- if store is None:
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 = None, **kwargs):
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
@@ -4,6 +4,7 @@ from typing import Any
4
4
  import httpx
5
5
  from loguru import logger
6
6
  from mcp.server.fastmcp import FastMCP
7
+ from mcp.server.fastmcp.server import MCPTool
7
8
  from mcp.types import TextContent
8
9
  from pydantic import ValidationError
9
10
 
@@ -13,7 +14,7 @@ from universal_mcp.exceptions import ConfigurationError, ToolError
13
14
  from universal_mcp.integrations import AgentRIntegration, integration_from_config
14
15
  from universal_mcp.stores import BaseStore, store_from_config
15
16
  from universal_mcp.tools import ToolManager
16
- from universal_mcp.tools.adapters import ToolFormat
17
+ from universal_mcp.tools.adapters import ToolFormat, format_to_mcp_result
17
18
  from universal_mcp.utils.agentr import AgentrClient
18
19
 
19
20
 
@@ -34,6 +35,7 @@ class BaseServer(FastMCP):
34
35
  logger.info(f"Initializing server: {config.name} ({config.type}) with store: {config.store}")
35
36
  self.config = config
36
37
  self._tool_manager = tool_manager or ToolManager(warn_on_duplicate_tools=True)
38
+ # Validate config after setting attributes to ensure proper initialization
37
39
  ServerConfig.model_validate(config)
38
40
  except Exception as e:
39
41
  logger.error(f"Failed to initialize server: {e}", exc_info=True)
@@ -48,10 +50,9 @@ class BaseServer(FastMCP):
48
50
  Raises:
49
51
  ValueError: If tool is invalid
50
52
  """
51
-
52
53
  self._tool_manager.add_tool(tool)
53
54
 
54
- async def list_tools(self) -> list[dict]:
55
+ async def list_tools(self) -> list:
55
56
  """List all available tools in MCP format.
56
57
 
57
58
  Returns:
@@ -59,23 +60,6 @@ class BaseServer(FastMCP):
59
60
  """
60
61
  return self._tool_manager.list_tools(format=ToolFormat.MCP)
61
62
 
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
63
  async def call_tool(self, name: str, arguments: dict[str, Any]) -> list[TextContent]:
80
64
  """Call a tool with comprehensive error handling.
81
65
 
@@ -99,7 +83,7 @@ class BaseServer(FastMCP):
99
83
  try:
100
84
  result = await self._tool_manager.call_tool(name, arguments)
101
85
  logger.info(f"Tool '{name}' completed successfully")
102
- return self._format_tool_result(result)
86
+ return format_to_mcp_result(result)
103
87
  except Exception as e:
104
88
  logger.error(f"Tool '{name}' failed: {e}", exc_info=True)
105
89
  raise ToolError(f"Tool execution failed: {str(e)}") from e
@@ -219,12 +203,23 @@ class AgentRServer(BaseServer):
219
203
  """
220
204
 
221
205
  def __init__(self, config: ServerConfig, **kwargs):
206
+ # Initialize API key and client before calling super().__init__
222
207
  self.api_key = config.api_key.get_secret_value() if config.api_key else None
208
+ if not self.api_key:
209
+ raise ValueError("API key is required for AgentR server")
210
+
223
211
  logger.info(f"Initializing AgentR server with API key: {self.api_key}")
224
212
  self.client = AgentrClient(api_key=self.api_key)
225
213
  super().__init__(config, **kwargs)
226
- self.integration = AgentRIntegration(name="agentr", api_key=self.client.api_key)
227
- self._load_apps()
214
+ self.integration = AgentRIntegration(name="agentr", api_key=self.api_key)
215
+ # Don't load apps in __init__ for stateless operation
216
+ self._apps_loaded = False
217
+
218
+ def _ensure_apps_loaded(self) -> None:
219
+ """Ensure apps are loaded, loading them if necessary."""
220
+ if not self._apps_loaded:
221
+ self._load_apps()
222
+ self._apps_loaded = True
228
223
 
229
224
  def _fetch_apps(self) -> list[AppConfig]:
230
225
  """Fetch available apps from AgentR API with retry logic.
@@ -297,6 +292,16 @@ class AgentRServer(BaseServer):
297
292
  # Don't raise the exception to allow server to start with partial functionality
298
293
  logger.warning("Server will start with limited functionality due to app loading failures")
299
294
 
295
+ async def list_tools(self) -> list[MCPTool]:
296
+ """List available tools, ensuring apps are loaded first."""
297
+ self._ensure_apps_loaded()
298
+ return await super().list_tools()
299
+
300
+ async def call_tool(self, name: str, arguments: dict) -> list[TextContent]:
301
+ """Call a tool by name, ensuring apps are loaded first."""
302
+ self._ensure_apps_loaded()
303
+ return await super().call_tool(name, arguments)
304
+
300
305
 
301
306
  class SingleMCPServer(BaseServer):
302
307
  """
@@ -322,12 +327,12 @@ class SingleMCPServer(BaseServer):
322
327
  **kwargs,
323
328
  ):
324
329
  if not app_instance:
325
- raise ValueError("app_instance is required")
326
- if not config:
327
- config = ServerConfig(
328
- type="local",
329
- name=f"{app_instance.name.title()} MCP Server for Local Development",
330
- description=f"Minimal MCP server for the local {app_instance.name} application.",
331
- )
330
+ raise ValueError("app_instance is required for SingleMCPServer")
331
+
332
+ config = config or ServerConfig(
333
+ type="local",
334
+ name=f"{app_instance.name.title()} MCP Server for Local Development",
335
+ description=f"Minimal MCP server for the local {app_instance.name} application.",
336
+ )
332
337
  super().__init__(config, **kwargs)
333
338
  self._tool_manager.register_tools_from_app(app_instance, tags="all")
@@ -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
 
@@ -23,6 +26,24 @@ def convert_tool_to_mcp_tool(
23
26
  )
24
27
 
25
28
 
29
+ def format_to_mcp_result(result: any) -> list[TextContent]:
30
+ """Format tool result into TextContent list.
31
+
32
+ Args:
33
+ result: Raw tool result
34
+
35
+ Returns:
36
+ List of TextContent objects
37
+ """
38
+ if isinstance(result, str):
39
+ return [TextContent(type="text", text=result)]
40
+ elif isinstance(result, list) and all(isinstance(item, TextContent) for item in result):
41
+ return result
42
+ else:
43
+ logger.warning(f"Tool returned unexpected type: {type(result)}. Wrapping in TextContent.")
44
+ return [TextContent(type="text", text=str(result))]
45
+
46
+
26
47
  def convert_tool_to_langchain_tool(
27
48
  tool: Tool,
28
49
  ):
@@ -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
- logger.debug(f"Filtering tools by name: {tool_names}")
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 name in tool_names:
30
- if re.search(name, tool.name):
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
- if not tags:
41
- return tools
42
- return [tool for tool in tools if any(tag in tool.tags for tag in tags)]
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._tools: dict[str, Tool] = {}
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._tools.get(name)
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
- ) -> list[Tool]:
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
- tools = list(self._tools.values())
90
- if tags:
91
- tools = _filter_by_tags(tools, tags)
153
+ # Convert to requested format
92
154
  if format == ToolFormat.MCP:
93
- tools = [convert_tool_to_mcp_tool(tool) for tool in tools]
155
+ return [convert_tool_to_mcp_tool(tool) for tool in tools]
94
156
  elif format == ToolFormat.LANGCHAIN:
95
- tools = [convert_tool_to_langchain_tool(tool) for tool in tools]
157
+ return [convert_tool_to_langchain_tool(tool) for tool in tools]
96
158
  elif format == ToolFormat.OPENAI:
97
- tools = [convert_tool_to_openai_tool(tool) for tool in tools]
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(self, fn: Callable[..., Any] | Tool, name: str | None = None) -> 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
- if not tool.name or not isinstance(tool.name, str):
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._tools[tool.name] = tool
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._tools:
150
- del self._tools[name]
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._tools.clear()
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
- tool_instance.tags.append(app.name) if app.name not in tool_instance.tags else None
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
- # If no tool names or tags are provided, use the default important tag
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
- self.register_tools(tools)
208
- return
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
@@ -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 | None = None, base_url: str | None = None):
22
- if api_key:
23
- self.api_key = api_key
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
  )