universal-mcp 0.1.15rc5__py3-none-any.whl → 0.1.16__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.
Files changed (37) hide show
  1. universal_mcp/analytics.py +7 -1
  2. universal_mcp/applications/README.md +122 -0
  3. universal_mcp/applications/__init__.py +51 -56
  4. universal_mcp/applications/application.py +255 -82
  5. universal_mcp/cli.py +27 -43
  6. universal_mcp/config.py +16 -48
  7. universal_mcp/exceptions.py +8 -0
  8. universal_mcp/integrations/__init__.py +1 -3
  9. universal_mcp/integrations/integration.py +18 -2
  10. universal_mcp/logger.py +31 -29
  11. universal_mcp/servers/server.py +6 -18
  12. universal_mcp/stores/store.py +2 -12
  13. universal_mcp/tools/__init__.py +12 -1
  14. universal_mcp/tools/adapters.py +11 -0
  15. universal_mcp/tools/func_metadata.py +11 -15
  16. universal_mcp/tools/manager.py +163 -117
  17. universal_mcp/tools/tools.py +6 -13
  18. universal_mcp/utils/agentr.py +2 -6
  19. universal_mcp/utils/common.py +33 -0
  20. universal_mcp/utils/docstring_parser.py +4 -13
  21. universal_mcp/utils/installation.py +67 -184
  22. universal_mcp/utils/openapi/__inti__.py +0 -0
  23. universal_mcp/utils/{api_generator.py → openapi/api_generator.py} +2 -4
  24. universal_mcp/utils/{docgen.py → openapi/docgen.py} +17 -54
  25. universal_mcp/utils/openapi/openapi.py +882 -0
  26. universal_mcp/utils/openapi/preprocessor.py +1093 -0
  27. universal_mcp/utils/{readme.py → openapi/readme.py} +21 -37
  28. universal_mcp-0.1.16.dist-info/METADATA +282 -0
  29. universal_mcp-0.1.16.dist-info/RECORD +44 -0
  30. universal_mcp-0.1.16.dist-info/licenses/LICENSE +21 -0
  31. universal_mcp/utils/openapi.py +0 -646
  32. universal_mcp-0.1.15rc5.dist-info/METADATA +0 -245
  33. universal_mcp-0.1.15rc5.dist-info/RECORD +0 -39
  34. /universal_mcp/{templates → utils/templates}/README.md.j2 +0 -0
  35. /universal_mcp/{templates → utils/templates}/api_client.py.j2 +0 -0
  36. {universal_mcp-0.1.15rc5.dist-info → universal_mcp-0.1.16.dist-info}/WHEEL +0 -0
  37. {universal_mcp-0.1.15rc5.dist-info → universal_mcp-0.1.16.dist-info}/entry_points.txt +0 -0
@@ -1,5 +1,5 @@
1
1
  from collections.abc import Callable
2
- from typing import Any, Literal
2
+ from typing import Any
3
3
 
4
4
  from loguru import logger
5
5
 
@@ -7,98 +7,159 @@ from universal_mcp.analytics import analytics
7
7
  from universal_mcp.applications.application import BaseApplication
8
8
  from universal_mcp.exceptions import ToolError
9
9
  from universal_mcp.tools.adapters import (
10
+ ToolFormat,
10
11
  convert_tool_to_langchain_tool,
11
12
  convert_tool_to_mcp_tool,
12
13
  convert_tool_to_openai_tool,
13
14
  )
14
15
  from universal_mcp.tools.tools import Tool
15
16
 
17
+ # Constants
18
+ DEFAULT_IMPORTANT_TAG = "important"
19
+ TOOL_NAME_SEPARATOR = "_"
20
+
21
+
22
+ def _filter_by_name(tools: list[Tool], tool_names: list[str]) -> list[Tool]:
23
+ if not tool_names:
24
+ return tools
25
+ return [tool for tool in tools if tool.name in tool_names]
26
+
27
+
28
+ def _filter_by_tags(tools: list[Tool], tags: list[str] | None) -> list[Tool]:
29
+ tags = tags or [DEFAULT_IMPORTANT_TAG]
30
+ return [tool for tool in tools if any(tag in tool.tags for tag in tags)]
31
+
16
32
 
17
33
  class ToolManager:
18
- """Manages FastMCP tools."""
34
+ """Manages FastMCP tools.
35
+
36
+ This class provides functionality for registering, managing, and executing tools.
37
+ It supports multiple tool formats and provides filtering capabilities based on names and tags.
38
+ """
19
39
 
20
40
  def __init__(self, warn_on_duplicate_tools: bool = True):
41
+ """Initialize the ToolManager.
42
+
43
+ Args:
44
+ warn_on_duplicate_tools: Whether to warn when duplicate tool names are detected.
45
+ """
21
46
  self._tools: dict[str, Tool] = {}
22
47
  self.warn_on_duplicate_tools = warn_on_duplicate_tools
23
48
 
24
49
  def get_tool(self, name: str) -> Tool | None:
25
- """Get tool by name."""
50
+ """Get tool by name.
51
+
52
+ Args:
53
+ name: The name of the tool to retrieve.
54
+
55
+ Returns:
56
+ The Tool instance if found, None otherwise.
57
+ """
26
58
  return self._tools.get(name)
27
59
 
28
60
  def list_tools(
29
- self, format: Literal["mcp", "langchain", "openai"] = "mcp"
61
+ self,
62
+ format: ToolFormat = ToolFormat.MCP,
63
+ tags: list[str] | None = None,
30
64
  ) -> list[Tool]:
31
- """List all registered tools."""
32
- if format == "mcp":
33
- return [convert_tool_to_mcp_tool(tool) for tool in self._tools.values()]
34
- elif format == "langchain":
35
- return [
36
- convert_tool_to_langchain_tool(tool) for tool in self._tools.values()
37
- ]
38
- elif format == "openai":
39
- return [convert_tool_to_openai_tool(tool) for tool in self._tools.values()]
65
+ """List all registered tools in the specified format.
66
+
67
+ Args:
68
+ format: The format to convert tools to.
69
+
70
+ Returns:
71
+ List of tools in the specified format.
72
+
73
+ Raises:
74
+ ValueError: If an invalid format is provided.
75
+ """
76
+
77
+ tools = list(self._tools.values())
78
+ if tags:
79
+ tools = _filter_by_tags(tools, tags)
80
+
81
+ if format == ToolFormat.MCP:
82
+ tools = [convert_tool_to_mcp_tool(tool) for tool in tools]
83
+ elif format == ToolFormat.LANGCHAIN:
84
+ tools = [convert_tool_to_langchain_tool(tool) for tool in tools]
85
+ elif format == ToolFormat.OPENAI:
86
+ tools = [convert_tool_to_openai_tool(tool) for tool in tools]
40
87
  else:
41
88
  raise ValueError(f"Invalid format: {format}")
42
89
 
43
- # Modified add_tool to accept name override explicitly
44
- def add_tool(
45
- self, fn: Callable[..., Any] | Tool, name: str | None = None
46
- ) -> Tool: # Changed any to Any
47
- """Add a tool to the server, allowing name override."""
48
- # Create the Tool object using the provided name if available
90
+ return tools
91
+
92
+ def add_tool(self, fn: Callable[..., Any] | Tool, name: str | None = None) -> Tool:
93
+ """Add a tool to the manager.
94
+
95
+ Args:
96
+ fn: The tool function or Tool instance to add.
97
+ name: Optional name override for the tool.
98
+
99
+ Returns:
100
+ The registered Tool instance.
101
+
102
+ Raises:
103
+ ValueError: If the tool name is invalid.
104
+ """
49
105
  tool = fn if isinstance(fn, Tool) else Tool.from_function(fn, name=name)
106
+
107
+ if not tool.name or not isinstance(tool.name, str):
108
+ raise ValueError("Tool name must be a non-empty string")
109
+
50
110
  existing = self._tools.get(tool.name)
51
111
  if existing:
52
112
  if self.warn_on_duplicate_tools:
53
- # Check if it's the *exact* same function object being added again
54
113
  if existing.fn is not tool.fn:
55
114
  logger.warning(
56
115
  f"Tool name '{tool.name}' conflicts with an existing tool. Skipping addition of new function."
57
116
  )
58
117
  else:
59
- logger.debug(
60
- f"Tool '{tool.name}' with the same function already exists."
61
- )
62
- return existing # Return the existing tool if name conflicts
118
+ logger.debug(f"Tool '{tool.name}' with the same function already exists.")
119
+ return existing
63
120
 
64
121
  logger.debug(f"Adding tool: {tool.name}")
65
122
  self._tools[tool.name] = tool
66
123
  return tool
67
124
 
68
- async def call_tool(
69
- self,
70
- name: str,
71
- arguments: dict[str, Any],
72
- context=None,
73
- ) -> Any:
74
- """Call a tool by name with arguments."""
75
- tool = self.get_tool(name)
76
- if not tool:
77
- raise ToolError(f"Unknown tool: {name}")
78
- try:
79
- result = await tool.run(arguments)
80
- analytics.track_tool_called(name, "success")
81
- return result
82
- except Exception as e:
83
- analytics.track_tool_called(name, "error", str(e))
84
- raise
125
+ def register_tools(self, tools: list[Tool]) -> None:
126
+ """Register a list of tools."""
127
+ for tool in tools:
128
+ self.add_tool(tool)
85
129
 
86
- def get_tools_by_tags(self, tags: list[str]) -> list[Tool]:
87
- """Get tools by tags."""
88
- return [
89
- tool
90
- for tool in self._tools.values()
91
- if any(tag in tool.tags for tag in tags)
92
- ]
130
+ def remove_tool(self, name: str) -> bool:
131
+ """Remove a tool by name.
132
+
133
+ Args:
134
+ name: The name of the tool to remove.
135
+
136
+ Returns:
137
+ True if the tool was removed, False if it didn't exist.
138
+ """
139
+ if name in self._tools:
140
+ del self._tools[name]
141
+ return True
142
+ return False
143
+
144
+ def clear_tools(self) -> None:
145
+ """Remove all registered tools."""
146
+ self._tools.clear()
93
147
 
94
148
  def register_tools_from_app(
95
149
  self,
96
150
  app: BaseApplication,
97
- tools: list[str] | None = None,
98
- tags: list[str] | None = None,
151
+ tool_names: list[str] = None,
152
+ tags: list[str] = None,
99
153
  ) -> None:
154
+ """Register tools from an application.
155
+
156
+ Args:
157
+ app: The application to register tools from.
158
+ tools: Optional list of specific tool names to register.
159
+ tags: Optional list of tags to filter tools by.
160
+ """
100
161
  try:
101
- available_tool_functions = app.list_tools()
162
+ functions = app.list_tools()
102
163
  except TypeError as e:
103
164
  logger.error(f"Error calling list_tools for app '{app.name}'. Error: {e}")
104
165
  return
@@ -106,75 +167,60 @@ class ToolManager:
106
167
  logger.error(f"Failed to get tool list from app '{app.name}': {e}")
107
168
  return
108
169
 
109
- if not isinstance(available_tool_functions, list):
110
- logger.error(
111
- f"App '{app.name}' list_tools() did not return a list. Skipping registration."
112
- )
170
+ if not isinstance(functions, list):
171
+ logger.error(f"App '{app.name}' list_tools() did not return a list. Skipping registration.")
113
172
  return
114
173
 
115
- # Determine the effective filter lists *before* the loop for efficiency
116
- # Use an empty list if None is passed, simplifies checks later
117
- tools_name_filter = tools or []
118
-
119
- # For tags, determine the filter list based on priority: passed 'tags' or default 'important'
120
- # This list is only used if tools_name_filter is empty.
121
- active_tags_filter = tags if tags else ["important"] # Default filter
122
-
123
- logger.debug(
124
- f"Registering tools for '{app.name}'. Name filter: {tools_name_filter or 'None'}. Tag filter (if name filter empty): {active_tags_filter}"
125
- )
126
-
127
- for tool_func in available_tool_functions:
128
- if not callable(tool_func):
129
- logger.warning(
130
- f"Item returned by {app.name}.list_tools() is not callable: {tool_func}. Skipping."
131
- )
174
+ tools = []
175
+ for function in functions:
176
+ if not callable(function):
177
+ logger.warning(f"Non-callable tool from {app.name}: {function}")
132
178
  continue
133
179
 
134
180
  try:
135
- # Create the Tool metadata object from the function.
136
- # This parses docstring (including tags), gets signature etc.
137
- tool_instance = Tool.from_function(tool_func)
181
+ tool_instance = Tool.from_function(function)
182
+ tool_instance.name = f"{app.name}{TOOL_NAME_SEPARATOR}{tool_instance.name}"
183
+ tool_instance.tags.append(app.name) if app.name not in tool_instance.tags else None
184
+ tools.append(tool_instance)
138
185
  except Exception as e:
139
- logger.error(
140
- f"Failed to create Tool object from function '{getattr(tool_func, '__name__', 'unknown')}' in app '{app.name}': {e}"
141
- )
142
- continue # Skip this tool if metadata creation fails
143
-
144
- # --- Modify the Tool instance before filtering/registration ---
145
- original_name = tool_instance.name
146
- prefixed_name = f"{app.name}_{original_name}"
147
- tool_instance.name = prefixed_name # Update the name
148
-
149
- # Add the app name itself as a tag for categorization
150
- if app.name not in tool_instance.tags:
151
- tool_instance.tags.append(app.name)
152
-
153
- # --- Filtering Logic ---
154
- should_register = False # Default to not registering
155
-
156
- if tools_name_filter:
157
- # --- Primary Filter: Check against specific tool names ---
158
- if tool_instance.name in tools_name_filter:
159
- should_register = True
160
- logger.debug(f"Tool '{tool_instance.name}' matched name filter.")
161
- # If not in the name filter, it's skipped (should_register remains False)
162
-
163
- else:
164
- # --- Secondary Filter: Check against tags (since tools_name_filter is empty) ---
165
- # Check if *any* tag in active_tags_filter exists in the tool's tags
166
- # tool_instance.tags includes tags parsed from the docstring + app.name
167
- if any(tag in tool_instance.tags for tag in active_tags_filter):
168
- should_register = True
169
- logger.debug(
170
- f"Tool '{tool_instance.name}' matched tag filter {active_tags_filter}."
171
- )
172
- # else:
173
- # logger.debug(f"Tool '{tool_instance.name}' did NOT match tag filter {active_tags_filter}. Tool tags: {tool_instance.tags}")
174
-
175
- # --- Add the tool if it passed the filters ---
176
- if should_register:
177
- # Pass the fully configured Tool *instance* to add_tool
178
- self.add_tool(tool_instance)
179
- # else: If not registered, optionally log it for debugging:
180
- # logger.trace(f"Tool '{tool_instance.name}' skipped due to filters.") # Use trace level
186
+ tool_name = getattr(function, "__name__", "unknown")
187
+ logger.error(f"Failed to create Tool from '{tool_name}' in {app.name}: {e}")
188
+
189
+ tools = _filter_by_name(tools, tool_names)
190
+ tools = _filter_by_tags(tools, tags)
191
+ self.register_tools(tools)
192
+ return
193
+
194
+ async def call_tool(
195
+ self,
196
+ name: str,
197
+ arguments: dict[str, Any],
198
+ context: dict[str, Any] | None = None,
199
+ ) -> Any:
200
+ """Call a tool by name with arguments.
201
+
202
+ Args:
203
+ name: The name of the tool to call.
204
+ arguments: The arguments to pass to the tool.
205
+ context: Optional context information for the tool execution.
206
+
207
+ Returns:
208
+ The result of the tool execution.
209
+
210
+ Raises:
211
+ ToolError: If the tool is not found or execution fails.
212
+ """
213
+ logger.debug(f"Calling tool: {name} with arguments: {arguments}")
214
+ tool = self.get_tool(name)
215
+ if not tool:
216
+ raise ToolError(f"Unknown tool: {name}")
217
+
218
+ try:
219
+ result = await tool.run(arguments, context)
220
+ app_name = tool.name.split(TOOL_NAME_SEPARATOR)[0]
221
+ analytics.track_tool_called(name, app_name, "success")
222
+ return result
223
+ except Exception as e:
224
+ app_name = tool.name.split(TOOL_NAME_SEPARATOR)[0]
225
+ analytics.track_tool_called(name, app_name, "error", str(e))
226
+ raise ToolError(f"Tool execution failed: {str(e)}") from e
@@ -20,20 +20,15 @@ class Tool(BaseModel):
20
20
  args_description: dict[str, str] = Field(
21
21
  default_factory=dict, description="Descriptions of arguments from the docstring"
22
22
  )
23
- returns_description: str = Field(
24
- default="", description="Description of the return value from the docstring"
25
- )
23
+ returns_description: str = Field(default="", description="Description of the return value from the docstring")
26
24
  raises_description: dict[str, str] = Field(
27
25
  default_factory=dict,
28
26
  description="Descriptions of exceptions raised from the docstring",
29
27
  )
30
- tags: list[str] = Field(
31
- default_factory=list, description="Tags for categorizing the tool"
32
- )
28
+ tags: list[str] = Field(default_factory=list, description="Tags for categorizing the tool")
33
29
  parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
34
30
  fn_metadata: FuncMetadata = Field(
35
- description="Metadata about the function including a pydantic model for tool"
36
- " arguments"
31
+ description="Metadata about the function including a pydantic model for tool arguments"
37
32
  )
38
33
  is_async: bool = Field(description="Whether the tool is async")
39
34
 
@@ -55,9 +50,7 @@ class Tool(BaseModel):
55
50
 
56
51
  is_async = inspect.iscoroutinefunction(fn)
57
52
 
58
- func_arg_metadata = FuncMetadata.func_metadata(
59
- fn,
60
- )
53
+ func_arg_metadata = FuncMetadata.func_metadata(fn, arg_description=parsed_doc["args"])
61
54
  parameters = func_arg_metadata.arg_model.model_json_schema()
62
55
 
63
56
  return cls(
@@ -76,12 +69,12 @@ class Tool(BaseModel):
76
69
  async def run(
77
70
  self,
78
71
  arguments: dict[str, Any],
79
- context=None,
72
+ context: dict[str, Any] | None = None,
80
73
  ) -> Any:
81
74
  """Run the tool with arguments."""
82
75
  try:
83
76
  return await self.fn_metadata.call_fn_with_arg_validation(
84
- self.fn, self.is_async, arguments, None
77
+ self.fn, self.is_async, arguments, None, context=context
85
78
  )
86
79
  except NotAuthorizedError as e:
87
80
  message = f"Not authorized to call tool {self.name}: {e.message}"
@@ -26,9 +26,7 @@ class AgentrClient(metaclass=Singleton):
26
26
  "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."
27
27
  )
28
28
  raise ValueError("AgentR API key required - get one at https://agentr.dev")
29
- self.base_url = (
30
- base_url or os.getenv("AGENTR_BASE_URL", "https://api.agentr.dev")
31
- ).rstrip("/")
29
+ self.base_url = (base_url or os.getenv("AGENTR_BASE_URL", "https://api.agentr.dev")).rstrip("/")
32
30
 
33
31
  def get_credentials(self, integration_name: str) -> dict:
34
32
  """Get credentials for an integration from the AgentR API.
@@ -48,9 +46,7 @@ class AgentrClient(metaclass=Singleton):
48
46
  headers={"accept": "application/json", "X-API-KEY": self.api_key},
49
47
  )
50
48
  if response.status_code == 404:
51
- logger.warning(
52
- f"No credentials found for {integration_name}. Requesting authorization..."
53
- )
49
+ logger.warning(f"No credentials found for {integration_name}. Requesting authorization...")
54
50
  action = self.get_authorization_url(integration_name)
55
51
  raise NotAuthorizedError(action)
56
52
  response.raise_for_status()
@@ -0,0 +1,33 @@
1
+ def get_default_repository_path(slug: str) -> str:
2
+ """
3
+ Convert a repository slug to a repository URL.
4
+ """
5
+ slug = slug.strip().lower()
6
+ return f"universal-mcp-{slug}"
7
+
8
+
9
+ def get_default_package_name(slug: str) -> str:
10
+ """
11
+ Convert a repository slug to a package name.
12
+ """
13
+ slug = slug.strip().lower()
14
+ package_name = f"universal_mcp_{slug.replace('-', '_')}"
15
+ return package_name
16
+
17
+
18
+ def get_default_module_path(slug: str) -> str:
19
+ """
20
+ Convert a repository slug to a module path.
21
+ """
22
+ package_name = get_default_package_name(slug)
23
+ module_path = f"{package_name}.app"
24
+ return module_path
25
+
26
+
27
+ def get_default_class_name(slug: str) -> str:
28
+ """
29
+ Convert a repository slug to a class name.
30
+ """
31
+ slug = slug.strip().lower()
32
+ class_name = "".join(part.capitalize() for part in slug.split("-")) + "App"
33
+ return class_name
@@ -81,17 +81,13 @@ def parse_docstring(docstring: str | None) -> dict[str, Any]:
81
81
  elif stripped_lower.startswith(("raises ", "errors ", "exceptions ")):
82
82
  section_type = "raises"
83
83
  # Capture content after header word and potential colon/space
84
- parts = re.split(
85
- r"[:\s]+", line.strip(), maxsplit=1
86
- ) # B034: Use keyword maxsplit
84
+ parts = re.split(r"[:\s]+", line.strip(), maxsplit=1) # B034: Use keyword maxsplit
87
85
  if len(parts) > 1:
88
86
  header_content = parts[1].strip()
89
87
  elif stripped_lower.startswith(("tags",)):
90
88
  section_type = "tags"
91
89
  # Capture content after header word and potential colon/space
92
- parts = re.split(
93
- r"[:\s]+", line.strip(), maxsplit=1
94
- ) # B034: Use keyword maxsplit
90
+ parts = re.split(r"[:\s]+", line.strip(), maxsplit=1) # B034: Use keyword maxsplit
95
91
  if len(parts) > 1:
96
92
  header_content = parts[1].strip()
97
93
 
@@ -117,9 +113,7 @@ def parse_docstring(docstring: str | None) -> dict[str, Any]:
117
113
  stripped_line = line.strip()
118
114
  original_indentation = len(line) - len(line.lstrip(" "))
119
115
 
120
- is_new_section_header, new_section_type_this_line, header_content_this_line = (
121
- check_for_section_header(line)
122
- )
116
+ is_new_section_header, new_section_type_this_line, header_content_this_line = check_for_section_header(line)
123
117
 
124
118
  should_finalize_previous = False
125
119
 
@@ -156,10 +150,7 @@ def parse_docstring(docstring: str | None) -> dict[str, Any]:
156
150
  or (
157
151
  current_section in ["args", "raises"]
158
152
  and current_key is not None
159
- and (
160
- key_pattern.match(line)
161
- or (original_indentation == 0 and stripped_line)
162
- )
153
+ and (key_pattern.match(line) or (original_indentation == 0 and stripped_line))
163
154
  )
164
155
  or (
165
156
  current_section in ["returns", "tags", "other"]