universal-mcp 0.1.8rc2__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.
- universal_mcp/__init__.py +0 -2
- universal_mcp/analytics.py +75 -0
- universal_mcp/applications/application.py +27 -5
- universal_mcp/applications/calendly/app.py +413 -160
- universal_mcp/applications/coda/README.md +133 -0
- universal_mcp/applications/coda/__init__.py +0 -0
- universal_mcp/applications/coda/app.py +3704 -0
- universal_mcp/applications/e2b/app.py +6 -7
- universal_mcp/applications/firecrawl/app.py +1 -1
- universal_mcp/applications/github/app.py +41 -42
- universal_mcp/applications/google_calendar/app.py +20 -20
- universal_mcp/applications/google_docs/app.py +22 -29
- universal_mcp/applications/google_drive/app.py +53 -59
- universal_mcp/applications/google_mail/app.py +40 -40
- universal_mcp/applications/google_sheet/app.py +44 -51
- universal_mcp/applications/markitdown/app.py +4 -4
- universal_mcp/applications/notion/app.py +93 -83
- universal_mcp/applications/perplexity/app.py +5 -5
- universal_mcp/applications/reddit/app.py +32 -32
- universal_mcp/applications/resend/app.py +4 -4
- universal_mcp/applications/serpapi/app.py +4 -4
- universal_mcp/applications/tavily/app.py +4 -4
- universal_mcp/applications/wrike/app.py +566 -226
- universal_mcp/applications/youtube/app.py +626 -166
- universal_mcp/applications/zenquotes/app.py +3 -3
- universal_mcp/exceptions.py +1 -0
- universal_mcp/integrations/__init__.py +11 -2
- universal_mcp/integrations/integration.py +2 -2
- universal_mcp/logger.py +3 -56
- universal_mcp/servers/__init__.py +2 -1
- universal_mcp/servers/server.py +76 -77
- universal_mcp/stores/store.py +5 -3
- universal_mcp/tools/__init__.py +1 -1
- universal_mcp/tools/adapters.py +4 -1
- universal_mcp/tools/func_metadata.py +5 -6
- universal_mcp/tools/tools.py +108 -51
- universal_mcp/utils/docgen.py +121 -69
- universal_mcp/utils/docstring_parser.py +44 -21
- universal_mcp/utils/dump_app_tools.py +33 -23
- universal_mcp/utils/openapi.py +121 -47
- {universal_mcp-0.1.8rc2.dist-info → universal_mcp-0.1.8rc3.dist-info}/METADATA +2 -2
- universal_mcp-0.1.8rc3.dist-info/RECORD +75 -0
- universal_mcp-0.1.8rc2.dist-info/RECORD +0 -71
- {universal_mcp-0.1.8rc2.dist-info → universal_mcp-0.1.8rc3.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.8rc2.dist-info → universal_mcp-0.1.8rc3.dist-info}/entry_points.txt +0 -0
universal_mcp/tools/tools.py
CHANGED
@@ -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
|
-
|
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[
|
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
|
-
|
56
|
-
|
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
|
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,
|
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,
|
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
|
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(
|
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 [
|
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(
|
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
|
-
|
215
|
+
logger.warning(
|
216
|
+
f"Tool name '{tool.name}' conflicts with an existing tool. Skipping addition of new function."
|
217
|
+
)
|
182
218
|
else:
|
183
|
-
|
184
|
-
|
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],
|
194
|
-
context
|
195
|
-
) -> 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
|
-
|
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 [
|
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(
|
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
|
-
|
221
|
-
|
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
|
-
|
225
|
-
|
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(
|
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(
|
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
|
-
|
248
|
-
|
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
|
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
|
-
|
313
|
+
tool_instance.tags.append(app.name)
|
258
314
|
|
259
315
|
# --- Filtering Logic ---
|
260
|
-
should_register = False
|
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(
|
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
|