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