universal-mcp 0.1.8rc2__py3-none-any.whl → 0.1.8rc4__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 (53) hide show
  1. universal_mcp/__init__.py +0 -2
  2. universal_mcp/analytics.py +75 -0
  3. universal_mcp/applications/ahrefs/README.md +76 -0
  4. universal_mcp/applications/ahrefs/__init__.py +0 -0
  5. universal_mcp/applications/ahrefs/app.py +2291 -0
  6. universal_mcp/applications/application.py +94 -5
  7. universal_mcp/applications/calendly/app.py +412 -171
  8. universal_mcp/applications/coda/README.md +133 -0
  9. universal_mcp/applications/coda/__init__.py +0 -0
  10. universal_mcp/applications/coda/app.py +3671 -0
  11. universal_mcp/applications/e2b/app.py +8 -35
  12. universal_mcp/applications/figma/README.md +74 -0
  13. universal_mcp/applications/figma/__init__.py +0 -0
  14. universal_mcp/applications/figma/app.py +1261 -0
  15. universal_mcp/applications/firecrawl/app.py +3 -33
  16. universal_mcp/applications/github/app.py +41 -42
  17. universal_mcp/applications/google_calendar/app.py +20 -31
  18. universal_mcp/applications/google_docs/app.py +21 -46
  19. universal_mcp/applications/google_drive/app.py +53 -76
  20. universal_mcp/applications/google_mail/app.py +40 -56
  21. universal_mcp/applications/google_sheet/app.py +43 -68
  22. universal_mcp/applications/markitdown/app.py +4 -4
  23. universal_mcp/applications/notion/app.py +93 -83
  24. universal_mcp/applications/perplexity/app.py +4 -38
  25. universal_mcp/applications/reddit/app.py +32 -32
  26. universal_mcp/applications/resend/app.py +4 -22
  27. universal_mcp/applications/serpapi/app.py +6 -32
  28. universal_mcp/applications/tavily/app.py +4 -24
  29. universal_mcp/applications/wrike/app.py +565 -237
  30. universal_mcp/applications/youtube/app.py +625 -183
  31. universal_mcp/applications/zenquotes/app.py +3 -3
  32. universal_mcp/exceptions.py +1 -0
  33. universal_mcp/integrations/__init__.py +11 -2
  34. universal_mcp/integrations/agentr.py +27 -4
  35. universal_mcp/integrations/integration.py +14 -6
  36. universal_mcp/logger.py +3 -56
  37. universal_mcp/servers/__init__.py +2 -1
  38. universal_mcp/servers/server.py +73 -77
  39. universal_mcp/stores/store.py +5 -3
  40. universal_mcp/tools/__init__.py +1 -1
  41. universal_mcp/tools/adapters.py +4 -1
  42. universal_mcp/tools/func_metadata.py +5 -6
  43. universal_mcp/tools/tools.py +108 -51
  44. universal_mcp/utils/docgen.py +121 -69
  45. universal_mcp/utils/docstring_parser.py +44 -21
  46. universal_mcp/utils/dump_app_tools.py +33 -23
  47. universal_mcp/utils/installation.py +199 -8
  48. universal_mcp/utils/openapi.py +121 -47
  49. {universal_mcp-0.1.8rc2.dist-info → universal_mcp-0.1.8rc4.dist-info}/METADATA +2 -2
  50. universal_mcp-0.1.8rc4.dist-info/RECORD +81 -0
  51. universal_mcp-0.1.8rc2.dist-info/RECORD +0 -71
  52. {universal_mcp-0.1.8rc2.dist-info → universal_mcp-0.1.8rc4.dist-info}/WHEEL +0 -0
  53. {universal_mcp-0.1.8rc2.dist-info → universal_mcp-0.1.8rc4.dist-info}/entry_points.txt +0 -0
@@ -4,27 +4,44 @@ import inspect
4
4
  from collections.abc import Callable
5
5
  from typing import Any, Literal
6
6
 
7
+ import httpx
7
8
  from loguru import logger
8
9
  from pydantic import BaseModel, Field
9
10
 
11
+ from universal_mcp.analytics import analytics
10
12
  from universal_mcp.applications.application import Application
11
- from universal_mcp.exceptions import ToolError
13
+ from universal_mcp.exceptions import NotAuthorizedError, ToolError
12
14
  from universal_mcp.utils.docstring_parser import parse_docstring
13
15
 
14
16
  from .func_metadata import FuncMetadata
15
17
 
16
18
 
19
+ def convert_tool_to_openai_tool(
20
+ tool: Tool,
21
+ ):
22
+ """Convert a Tool object to an OpenAI function."""
23
+ return {
24
+ "type": "function",
25
+ "function": {
26
+ "name": tool.name,
27
+ "description": tool.description,
28
+ "parameters": tool.parameters,
29
+ },
30
+ }
31
+
32
+
17
33
  def convert_tool_to_mcp_tool(
18
34
  tool: Tool,
19
35
  ):
20
36
  from mcp.server.fastmcp.server import MCPTool
37
+
21
38
  return MCPTool(
22
39
  name=tool.name,
23
40
  description=tool.description or "",
24
41
  inputSchema=tool.parameters,
25
42
  )
26
43
 
27
- # MODIFY THIS FUNCTION:
44
+
28
45
  def convert_tool_to_langchain_tool(
29
46
  tool: Tool,
30
47
  ):
@@ -44,7 +61,9 @@ def convert_tool_to_langchain_tool(
44
61
  )
45
62
 
46
63
  async def call_tool(
47
- **arguments: dict[str, any], # arguments received here are validated by StructuredTool
64
+ **arguments: dict[
65
+ str, any
66
+ ], # arguments received here are validated by StructuredTool
48
67
  ):
49
68
  # tool.run already handles validation via fn_metadata.call_fn_with_arg_validation
50
69
  # It should be able to handle the validated/coerced types from StructuredTool
@@ -52,31 +71,30 @@ def convert_tool_to_langchain_tool(
52
71
  call_tool_result = await tool.run(arguments)
53
72
  return call_tool_result
54
73
  except ToolError as e:
55
- # Langchain expects ToolException for controlled errors
56
- raise ToolException(f"Error running tool '{tool.name}': {e}") from e
74
+ # Langchain expects ToolException for controlled errors
75
+ raise ToolException(f"Error running tool '{tool.name}': {e}") from e
57
76
  except Exception as e:
58
77
  # Catch unexpected errors
59
78
  raise ToolException(f"Unexpected error in tool '{tool.name}': {e}") from e
60
79
 
61
-
62
80
  return StructuredTool(
63
81
  name=tool.name,
64
- description=tool.description or f"Tool named {tool.name}.", # Provide fallback description
82
+ description=tool.description
83
+ or f"Tool named {tool.name}.", # Provide fallback description
65
84
  coroutine=call_tool,
66
- args_schema=tool.fn_metadata.arg_model, # <<< --- ADD THIS LINE
85
+ args_schema=tool.fn_metadata.arg_model, # <<< --- ADD THIS LINE
67
86
  # handle_tool_error=True, # Optional: Consider adding error handling config
68
87
  # return_direct=False, # Optional: Default is usually fine
69
88
  # response_format="content", # This field might not be valid for StructuredTool, check LangChain docs if needed. Let's remove for now.
70
89
  )
71
90
 
91
+
72
92
  class Tool(BaseModel):
73
93
  """Internal tool registration info."""
74
94
 
75
95
  fn: Callable[..., Any] = Field(exclude=True)
76
96
  name: str = Field(description="Name of the tool")
77
- description: str = Field(
78
- description="Summary line from the tool's docstring"
79
- )
97
+ description: str = Field(description="Summary line from the tool's docstring")
80
98
  args_description: dict[str, str] = Field(
81
99
  default_factory=dict, description="Descriptions of arguments from the docstring"
82
100
  )
@@ -84,11 +102,12 @@ class Tool(BaseModel):
84
102
  default="", description="Description of the return value from the docstring"
85
103
  )
86
104
  raises_description: dict[str, str] = Field(
87
- default_factory=dict, description="Descriptions of exceptions raised from the docstring"
105
+ default_factory=dict,
106
+ description="Descriptions of exceptions raised from the docstring",
88
107
  )
89
108
  tags: list[str] = Field(
90
109
  default_factory=list, description="Tags for categorizing the tool"
91
- )
110
+ )
92
111
  parameters: dict[str, Any] = Field(description="JSON schema for tool parameters")
93
112
  fn_metadata: FuncMetadata = Field(
94
113
  description="Metadata about the function including a pydantic model for tool"
@@ -111,7 +130,7 @@ class Tool(BaseModel):
111
130
 
112
131
  raw_doc = inspect.getdoc(fn)
113
132
  parsed_doc = parse_docstring(raw_doc)
114
-
133
+
115
134
  is_async = inspect.iscoroutinefunction(fn)
116
135
 
117
136
  func_arg_metadata = FuncMetadata.func_metadata(
@@ -135,19 +154,26 @@ class Tool(BaseModel):
135
154
  async def run(
136
155
  self,
137
156
  arguments: dict[str, Any],
138
- context = None,
157
+ context=None,
139
158
  ) -> Any:
140
159
  """Run the tool with arguments."""
141
160
  try:
142
161
  return await self.fn_metadata.call_fn_with_arg_validation(
143
- self.fn,
144
- self.is_async,
145
- arguments,
146
- None
162
+ self.fn, self.is_async, arguments, None
147
163
  )
164
+ except NotAuthorizedError as e:
165
+ message = f"Not authorized to call tool {self.name}: {e.message}"
166
+ return message
167
+ except httpx.HTTPError as e:
168
+ message = f"HTTP error calling tool {self.name}: {str(e)}"
169
+ raise ToolError(message) from e
170
+ except ValueError as e:
171
+ message = f"Invalid arguments for tool {self.name}: {e}"
172
+ raise ToolError(message) from e
148
173
  except Exception as e:
149
174
  raise ToolError(f"Error executing tool {self.name}: {e}") from e
150
-
175
+
176
+
151
177
  class ToolManager:
152
178
  """Manages FastMCP tools."""
153
179
 
@@ -159,17 +185,25 @@ class ToolManager:
159
185
  """Get tool by name."""
160
186
  return self._tools.get(name)
161
187
 
162
- def list_tools(self, format: Literal["mcp", "langchain"] = "mcp") -> list[Tool]:
188
+ def list_tools(
189
+ self, format: Literal["mcp", "langchain", "openai"] = "mcp"
190
+ ) -> list[Tool]:
163
191
  """List all registered tools."""
164
192
  if format == "mcp":
165
193
  return [convert_tool_to_mcp_tool(tool) for tool in self._tools.values()]
166
194
  elif format == "langchain":
167
- return [convert_tool_to_langchain_tool(tool) for tool in self._tools.values()]
195
+ return [
196
+ convert_tool_to_langchain_tool(tool) for tool in self._tools.values()
197
+ ]
198
+ elif format == "openai":
199
+ return [convert_tool_to_openai_tool(tool) for tool in self._tools.values()]
168
200
  else:
169
201
  raise ValueError(f"Invalid format: {format}")
170
202
 
171
203
  # Modified add_tool to accept name override explicitly
172
- def add_tool(self, fn: Callable[..., Any] | Tool, name: str | None = None) -> Tool: # Changed any to Any
204
+ def add_tool(
205
+ self, fn: Callable[..., Any] | Tool, name: str | None = None
206
+ ) -> Tool: # Changed any to Any
173
207
  """Add a tool to the server, allowing name override."""
174
208
  # Create the Tool object using the provided name if available
175
209
  tool = fn if isinstance(fn, Tool) else Tool.from_function(fn, name=name)
@@ -178,10 +212,14 @@ class ToolManager:
178
212
  if self.warn_on_duplicate_tools:
179
213
  # Check if it's the *exact* same function object being added again
180
214
  if existing.fn is not tool.fn:
181
- logger.warning(f"Tool name '{tool.name}' conflicts with an existing tool. Skipping addition of new function.")
215
+ logger.warning(
216
+ f"Tool name '{tool.name}' conflicts with an existing tool. Skipping addition of new function."
217
+ )
182
218
  else:
183
- logger.debug(f"Tool '{tool.name}' with the same function already exists.")
184
- return existing # Return the existing tool if name conflicts
219
+ logger.debug(
220
+ f"Tool '{tool.name}' with the same function already exists."
221
+ )
222
+ return existing # Return the existing tool if name conflicts
185
223
 
186
224
  logger.debug(f"Adding tool: {tool.name}")
187
225
  self._tools[tool.name] = tool
@@ -190,39 +228,51 @@ class ToolManager:
190
228
  async def call_tool(
191
229
  self,
192
230
  name: str,
193
- arguments: dict[str, Any], # Changed any to Any
194
- context = None,
195
- ) -> Any: # Changed any to Any
231
+ arguments: dict[str, Any],
232
+ context=None,
233
+ ) -> Any:
196
234
  """Call a tool by name with arguments."""
197
235
  tool = self.get_tool(name)
198
236
  if not tool:
199
237
  raise ToolError(f"Unknown tool: {name}")
200
-
201
- return await tool.run(arguments)
238
+ try:
239
+ result = await tool.run(arguments)
240
+ analytics.track_tool_called(name, "success")
241
+ return result
242
+ except Exception as e:
243
+ analytics.track_tool_called(name, "error", str(e))
244
+ raise
202
245
 
203
246
  def get_tools_by_tags(self, tags: list[str]) -> list[Tool]:
204
247
  """Get tools by tags."""
205
- return [tool for tool in self._tools.values() if any(tag in tool.tags for tag in tags)]
206
-
248
+ return [
249
+ tool
250
+ for tool in self._tools.values()
251
+ if any(tag in tool.tags for tag in tags)
252
+ ]
253
+
207
254
  def register_tools_from_app(
208
255
  self,
209
256
  app: Application,
210
257
  tools: list[str] | None = None,
211
- tags: list[str] | None = None
258
+ tags: list[str] | None = None,
212
259
  ) -> None:
213
-
214
260
  try:
215
261
  available_tool_functions = app.list_tools()
216
262
  except TypeError as e:
217
- logger.error(f"Error calling list_tools for app '{app.name}'. Does its list_tools method accept arguments? It shouldn't. Error: {e}")
263
+ logger.error(
264
+ f"Error calling list_tools for app '{app.name}'. Does its list_tools method accept arguments? It shouldn't. Error: {e}"
265
+ )
218
266
  return
219
267
  except Exception as e:
220
- logger.error(f"Failed to get tool list from app '{app.name}': {e}")
221
- return
268
+ logger.error(f"Failed to get tool list from app '{app.name}': {e}")
269
+ return
222
270
 
223
271
  if not isinstance(available_tool_functions, list):
224
- logger.error(f"App '{app.name}' list_tools() did not return a list. Skipping registration.")
225
- return
272
+ logger.error(
273
+ f"App '{app.name}' list_tools() did not return a list. Skipping registration."
274
+ )
275
+ return
226
276
 
227
277
  # Determine the effective filter lists *before* the loop for efficiency
228
278
  # Use an empty list if None is passed, simplifies checks later
@@ -230,13 +280,17 @@ class ToolManager:
230
280
 
231
281
  # For tags, determine the filter list based on priority: passed 'tags' or default 'important'
232
282
  # This list is only used if tools_name_filter is empty.
233
- active_tags_filter = tags if tags else ["important"]# Default filter
283
+ active_tags_filter = tags if tags else ["important"] # Default filter
234
284
 
235
- logger.debug(f"Registering tools for '{app.name}'. Name filter: {tools_name_filter or 'None'}. Tag filter (if name filter empty): {active_tags_filter}")
285
+ logger.debug(
286
+ f"Registering tools for '{app.name}'. Name filter: {tools_name_filter or 'None'}. Tag filter (if name filter empty): {active_tags_filter}"
287
+ )
236
288
 
237
289
  for tool_func in available_tool_functions:
238
290
  if not callable(tool_func):
239
- logger.warning(f"Item returned by {app.name}.list_tools() is not callable: {tool_func}. Skipping.")
291
+ logger.warning(
292
+ f"Item returned by {app.name}.list_tools() is not callable: {tool_func}. Skipping."
293
+ )
240
294
  continue
241
295
 
242
296
  try:
@@ -244,20 +298,22 @@ class ToolManager:
244
298
  # This parses docstring (including tags), gets signature etc.
245
299
  tool_instance = Tool.from_function(tool_func)
246
300
  except Exception as e:
247
- logger.error(f"Failed to create Tool object from function '{getattr(tool_func, '__name__', 'unknown')}' in app '{app.name}': {e}")
248
- continue # Skip this tool if metadata creation fails
301
+ logger.error(
302
+ f"Failed to create Tool object from function '{getattr(tool_func, '__name__', 'unknown')}' in app '{app.name}': {e}"
303
+ )
304
+ continue # Skip this tool if metadata creation fails
249
305
 
250
306
  # --- Modify the Tool instance before filtering/registration ---
251
307
  original_name = tool_instance.name
252
308
  prefixed_name = f"{app.name}_{original_name}"
253
- tool_instance.name = prefixed_name # Update the name
309
+ tool_instance.name = prefixed_name # Update the name
254
310
 
255
311
  # Add the app name itself as a tag for categorization
256
312
  if app.name not in tool_instance.tags:
257
- tool_instance.tags.append(app.name)
313
+ tool_instance.tags.append(app.name)
258
314
 
259
315
  # --- Filtering Logic ---
260
- should_register = False # Default to not registering
316
+ should_register = False # Default to not registering
261
317
 
262
318
  if tools_name_filter:
263
319
  # --- Primary Filter: Check against specific tool names ---
@@ -272,14 +328,15 @@ class ToolManager:
272
328
  # tool_instance.tags includes tags parsed from the docstring + app.name
273
329
  if any(tag in tool_instance.tags for tag in active_tags_filter):
274
330
  should_register = True
275
- logger.debug(f"Tool '{tool_instance.name}' matched tag filter {active_tags_filter}.")
331
+ logger.debug(
332
+ f"Tool '{tool_instance.name}' matched tag filter {active_tags_filter}."
333
+ )
276
334
  # else:
277
335
  # logger.debug(f"Tool '{tool_instance.name}' did NOT match tag filter {active_tags_filter}. Tool tags: {tool_instance.tags}")
278
336
 
279
-
280
337
  # --- Add the tool if it passed the filters ---
281
338
  if should_register:
282
339
  # Pass the fully configured Tool *instance* to add_tool
283
340
  self.add_tool(tool_instance)
284
341
  # else: If not registered, optionally log it for debugging:
285
- # logger.trace(f"Tool '{tool_instance.name}' skipped due to filters.") # Use trace level
342
+ # logger.trace(f"Tool '{tool_instance.name}' skipped due to filters.") # Use trace level