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