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
@@ -1,18 +1,31 @@
1
1
  import os
2
2
  from abc import ABC, abstractmethod
3
+ from typing import Any
3
4
 
4
5
  import keyring
5
6
  from loguru import logger
6
7
 
7
8
 
8
- class Store(ABC):
9
+ class StoreError(Exception):
10
+ """Base exception class for store-related errors."""
11
+
12
+ pass
13
+
14
+
15
+ class KeyNotFoundError(StoreError):
16
+ """Exception raised when a key is not found in the store."""
17
+
18
+ pass
19
+
20
+
21
+ class BaseStore(ABC):
9
22
  """
10
23
  Abstract base class defining the interface for credential stores.
11
24
  All credential stores must implement get, set and delete methods.
12
25
  """
13
26
 
14
27
  @abstractmethod
15
- def get(self, key: str):
28
+ def get(self, key: str) -> Any:
16
29
  """
17
30
  Retrieve a value from the store by key.
18
31
 
@@ -20,44 +33,54 @@ class Store(ABC):
20
33
  key (str): The key to look up
21
34
 
22
35
  Returns:
23
- The stored value if found, None otherwise
36
+ Any: The stored value
37
+
38
+ Raises:
39
+ KeyNotFoundError: If the key is not found in the store
40
+ StoreError: If there is an error accessing the store
24
41
  """
25
42
  pass
26
43
 
27
44
  @abstractmethod
28
- def set(self, key: str, value: str):
45
+ def set(self, key: str, value: str) -> None:
29
46
  """
30
47
  Store a value in the store with the given key.
31
48
 
32
49
  Args:
33
50
  key (str): The key to store the value under
34
51
  value (str): The value to store
52
+
53
+ Raises:
54
+ StoreError: If there is an error storing the value
35
55
  """
36
56
  pass
37
57
 
38
58
  @abstractmethod
39
- def delete(self, key: str):
59
+ def delete(self, key: str) -> None:
40
60
  """
41
61
  Delete a value from the store by key.
42
62
 
43
63
  Args:
44
64
  key (str): The key to delete
65
+
66
+ Raises:
67
+ KeyNotFoundError: If the key is not found in the store
68
+ StoreError: If there is an error deleting the value
45
69
  """
46
70
  pass
47
71
 
48
72
 
49
- class MemoryStore:
73
+ class MemoryStore(BaseStore):
50
74
  """
51
- Acts as credential store for the applications.
52
- Responsible for storing and retrieving credentials.
53
- Ideally should be a key value store that keeps data in memory.
75
+ In-memory credential store implementation.
76
+ Stores credentials in a dictionary that persists only for the duration of the program execution.
54
77
  """
55
78
 
56
79
  def __init__(self):
57
80
  """Initialize an empty dictionary to store the data."""
58
- self.data = {}
81
+ self.data: dict[str, str] = {}
59
82
 
60
- def get(self, key: str):
83
+ def get(self, key: str) -> Any:
61
84
  """
62
85
  Retrieve a value from the in-memory store by key.
63
86
 
@@ -65,11 +88,16 @@ class MemoryStore:
65
88
  key (str): The key to look up
66
89
 
67
90
  Returns:
68
- The stored value if found, None otherwise
91
+ Any: The stored value
92
+
93
+ Raises:
94
+ KeyNotFoundError: If the key is not found in the store
69
95
  """
70
- return self.data.get(key)
96
+ if key not in self.data:
97
+ raise KeyNotFoundError(f"Key '{key}' not found in memory store")
98
+ return self.data[key]
71
99
 
72
- def set(self, key: str, value: str):
100
+ def set(self, key: str, value: str) -> None:
73
101
  """
74
102
  Store a value in the in-memory store with the given key.
75
103
 
@@ -79,27 +107,28 @@ class MemoryStore:
79
107
  """
80
108
  self.data[key] = value
81
109
 
82
- def delete(self, key: str):
110
+ def delete(self, key: str) -> None:
83
111
  """
84
112
  Delete a value from the in-memory store by key.
85
113
 
86
114
  Args:
87
115
  key (str): The key to delete
116
+
117
+ Raises:
118
+ KeyNotFoundError: If the key is not found in the store
88
119
  """
120
+ if key not in self.data:
121
+ raise KeyNotFoundError(f"Key '{key}' not found in memory store")
89
122
  del self.data[key]
90
123
 
91
124
 
92
- class EnvironmentStore(Store):
125
+ class EnvironmentStore(BaseStore):
93
126
  """
94
- Store that uses environment variables to store credentials.
95
- Implements the Store interface using OS environment variables as the backend.
127
+ Environment variable-based credential store implementation.
128
+ Uses OS environment variables to store and retrieve credentials.
96
129
  """
97
130
 
98
- def __init__(self):
99
- """Initialize the environment store."""
100
- pass
101
-
102
- def get(self, key: str):
131
+ def get(self, key: str) -> Any:
103
132
  """
104
133
  Retrieve a value from environment variables by key.
105
134
 
@@ -107,11 +136,17 @@ class EnvironmentStore(Store):
107
136
  key (str): The environment variable name to look up
108
137
 
109
138
  Returns:
110
- dict: Dictionary containing the api_key from environment variable
139
+ Any: The stored value
140
+
141
+ Raises:
142
+ KeyNotFoundError: If the environment variable is not found
111
143
  """
112
- return {"api_key": os.getenv(key)}
144
+ value = os.getenv(key)
145
+ if value is None:
146
+ raise KeyNotFoundError(f"Environment variable '{key}' not found")
147
+ return value
113
148
 
114
- def set(self, key: str, value: str):
149
+ def set(self, key: str, value: str) -> None:
115
150
  """
116
151
  Set an environment variable.
117
152
 
@@ -121,20 +156,25 @@ class EnvironmentStore(Store):
121
156
  """
122
157
  os.environ[key] = value
123
158
 
124
- def delete(self, key: str):
159
+ def delete(self, key: str) -> None:
125
160
  """
126
161
  Delete an environment variable.
127
162
 
128
163
  Args:
129
164
  key (str): The environment variable name to delete
165
+
166
+ Raises:
167
+ KeyNotFoundError: If the environment variable is not found
130
168
  """
169
+ if key not in os.environ:
170
+ raise KeyNotFoundError(f"Environment variable '{key}' not found")
131
171
  del os.environ[key]
132
172
 
133
173
 
134
- class KeyringStore(Store):
174
+ class KeyringStore(BaseStore):
135
175
  """
136
- Store that uses keyring to store credentials.
137
- Implements the Store interface using system keyring as the backend.
176
+ System keyring-based credential store implementation.
177
+ Uses the system's secure credential storage facility via the keyring library.
138
178
  """
139
179
 
140
180
  def __init__(self, app_name: str = "universal_mcp"):
@@ -146,7 +186,7 @@ class KeyringStore(Store):
146
186
  """
147
187
  self.app_name = app_name
148
188
 
149
- def get(self, key: str):
189
+ def get(self, key: str) -> Any:
150
190
  """
151
191
  Retrieve a password from the system keyring.
152
192
 
@@ -154,28 +194,51 @@ class KeyringStore(Store):
154
194
  key (str): The key to look up
155
195
 
156
196
  Returns:
157
- The stored password if found, None otherwise
197
+ Any: The stored value
198
+
199
+ Raises:
200
+ KeyNotFoundError: If the key is not found in the keyring
201
+ StoreError: If there is an error accessing the keyring
158
202
  """
159
- logger.info(f"Getting password for {key} from keyring")
160
- return keyring.get_password(self.app_name, key)
203
+ try:
204
+ logger.info(f"Getting password for {key} from keyring")
205
+ value = keyring.get_password(self.app_name, key)
206
+ if value is None:
207
+ raise KeyNotFoundError(f"Key '{key}' not found in keyring")
208
+ return value
209
+ except Exception as e:
210
+ raise KeyNotFoundError(f"Key '{key}' not found in keyring") from e
161
211
 
162
- def set(self, key: str, value: str):
212
+ def set(self, key: str, value: str) -> None:
163
213
  """
164
214
  Store a password in the system keyring.
165
215
 
166
216
  Args:
167
217
  key (str): The key to store the password under
168
218
  value (str): The password to store
219
+
220
+ Raises:
221
+ StoreError: If there is an error storing in the keyring
169
222
  """
170
- logger.info(f"Setting password for {key} in keyring")
171
- keyring.set_password(self.app_name, key, value)
223
+ try:
224
+ logger.info(f"Setting password for {key} in keyring")
225
+ keyring.set_password(self.app_name, key, value)
226
+ except Exception as e:
227
+ raise StoreError(f"Error storing in keyring: {str(e)}") from e
172
228
 
173
- def delete(self, key: str):
229
+ def delete(self, key: str) -> None:
174
230
  """
175
231
  Delete a password from the system keyring.
176
232
 
177
233
  Args:
178
234
  key (str): The key to delete
235
+
236
+ Raises:
237
+ KeyNotFoundError: If the key is not found in the keyring
238
+ StoreError: If there is an error deleting from the keyring
179
239
  """
180
- logger.info(f"Deleting password for {key} from keyring")
181
- keyring.delete_password(self.app_name, key)
240
+ try:
241
+ logger.info(f"Deleting password for {key} from keyring")
242
+ keyring.delete_password(self.app_name, key)
243
+ except Exception as e:
244
+ raise KeyNotFoundError(f"Key '{key}' not found in keyring") from e
@@ -0,0 +1,3 @@
1
+ from .tools import Tool, ToolManager
2
+
3
+ __all__ = ["Tool", "ToolManager"]
@@ -0,0 +1,43 @@
1
+ from universal_mcp.tools.tools import Tool
2
+
3
+
4
+ def convert_tool_to_mcp_tool(
5
+ tool: Tool,
6
+ ):
7
+ from mcp.server.fastmcp.server import MCPTool
8
+
9
+ return MCPTool(
10
+ name=tool.name,
11
+ description=tool.description or "",
12
+ inputSchema=tool.parameters,
13
+ )
14
+
15
+
16
+ def convert_tool_to_langchain_tool(
17
+ tool: Tool,
18
+ ):
19
+ from langchain_core.tools import StructuredTool
20
+
21
+ """Convert an tool to a LangChain tool.
22
+
23
+ NOTE: this tool can be executed only in a context of an active MCP client session.
24
+
25
+ Args:
26
+ tool: Tool to convert
27
+
28
+ Returns:
29
+ a LangChain tool
30
+ """
31
+
32
+ async def call_tool(
33
+ **arguments: dict[str, any],
34
+ ):
35
+ call_tool_result = await tool.run(arguments)
36
+ return call_tool_result
37
+
38
+ return StructuredTool(
39
+ name=tool.name,
40
+ description=tool.description or "",
41
+ coroutine=call_tool,
42
+ response_format="content",
43
+ )
@@ -0,0 +1,213 @@
1
+ import inspect
2
+ import json
3
+ from collections.abc import Awaitable, Callable, Sequence
4
+ from typing import (
5
+ Annotated,
6
+ Any,
7
+ ForwardRef,
8
+ )
9
+
10
+ from mcp.server.fastmcp.exceptions import InvalidSignature
11
+ from pydantic import BaseModel, ConfigDict, Field, WithJsonSchema, create_model
12
+ from pydantic._internal._typing_extra import eval_type_backport
13
+ from pydantic.fields import FieldInfo
14
+ from pydantic_core import PydanticUndefined
15
+
16
+
17
+ def _get_typed_annotation(annotation: Any, globalns: dict[str, Any]) -> Any:
18
+ def try_eval_type(
19
+ value: Any, globalns: dict[str, Any], localns: dict[str, Any]
20
+ ) -> tuple[Any, bool]:
21
+ try:
22
+ return eval_type_backport(value, globalns, localns), True
23
+ except NameError:
24
+ return value, False
25
+
26
+ if isinstance(annotation, str):
27
+ annotation = ForwardRef(annotation)
28
+ annotation, status = try_eval_type(annotation, globalns, globalns)
29
+
30
+ # This check and raise could perhaps be skipped, and we (FastMCP) just call
31
+ # model_rebuild right before using it 🤷
32
+ if status is False:
33
+ raise InvalidSignature(f"Unable to evaluate type annotation {annotation}")
34
+
35
+ return annotation
36
+
37
+
38
+ def _get_typed_signature(call: Callable[..., Any]) -> inspect.Signature:
39
+ """Get function signature while evaluating forward references"""
40
+ signature = inspect.signature(call)
41
+ globalns = getattr(call, "__globals__", {})
42
+ typed_params = [
43
+ inspect.Parameter(
44
+ name=param.name,
45
+ kind=param.kind,
46
+ default=param.default,
47
+ annotation=_get_typed_annotation(param.annotation, globalns),
48
+ )
49
+ for param in signature.parameters.values()
50
+ ]
51
+ typed_signature = inspect.Signature(typed_params)
52
+ return typed_signature
53
+
54
+
55
+ class ArgModelBase(BaseModel):
56
+ """A model representing the arguments to a function."""
57
+
58
+ def model_dump_one_level(self) -> dict[str, Any]:
59
+ """Return a dict of the model's fields, one level deep.
60
+
61
+ That is, sub-models etc are not dumped - they are kept as pydantic models.
62
+ """
63
+ kwargs: dict[str, Any] = {}
64
+ for field_name in self.model_fields:
65
+ kwargs[field_name] = getattr(self, field_name)
66
+ return kwargs
67
+
68
+ model_config = ConfigDict(
69
+ arbitrary_types_allowed=True,
70
+ )
71
+
72
+
73
+ class FuncMetadata(BaseModel):
74
+ arg_model: Annotated[type[ArgModelBase], WithJsonSchema(None)]
75
+ # We can add things in the future like
76
+ # - Maybe some args are excluded from attempting to parse from JSON
77
+ # - Maybe some args are special (like context) for dependency injection
78
+
79
+ async def call_fn_with_arg_validation(
80
+ self,
81
+ fn: Callable[..., Any] | Awaitable[Any],
82
+ fn_is_async: bool,
83
+ arguments_to_validate: dict[str, Any],
84
+ arguments_to_pass_directly: dict[str, Any] | None,
85
+ ) -> Any:
86
+ """Call the given function with arguments validated and injected.
87
+
88
+ Arguments are first attempted to be parsed from JSON, then validated against
89
+ the argument model, before being passed to the function.
90
+ """
91
+ arguments_pre_parsed = self.pre_parse_json(arguments_to_validate)
92
+ arguments_parsed_model = self.arg_model.model_validate(arguments_pre_parsed)
93
+ arguments_parsed_dict = arguments_parsed_model.model_dump_one_level()
94
+
95
+ arguments_parsed_dict |= arguments_to_pass_directly or {}
96
+
97
+ if fn_is_async:
98
+ if isinstance(fn, Awaitable):
99
+ return await fn
100
+ return await fn(**arguments_parsed_dict)
101
+ if isinstance(fn, Callable):
102
+ return fn(**arguments_parsed_dict)
103
+ raise TypeError("fn must be either Callable or Awaitable")
104
+
105
+ def pre_parse_json(self, data: dict[str, Any]) -> dict[str, Any]:
106
+ """Pre-parse data from JSON.
107
+
108
+ Return a dict with same keys as input but with values parsed from JSON
109
+ if appropriate.
110
+
111
+ This is to handle cases like `["a", "b", "c"]` being passed in as JSON inside
112
+ a string rather than an actual list. Claude desktop is prone to this - in fact
113
+ it seems incapable of NOT doing this. For sub-models, it tends to pass
114
+ dicts (JSON objects) as JSON strings, which can be pre-parsed here.
115
+ """
116
+ new_data = data.copy() # Shallow copy
117
+ for field_name, _field_info in self.arg_model.model_fields.items():
118
+ if field_name not in data:
119
+ continue
120
+ if isinstance(data[field_name], str):
121
+ try:
122
+ pre_parsed = json.loads(data[field_name])
123
+ except json.JSONDecodeError:
124
+ continue # Not JSON - skip
125
+ if isinstance(pre_parsed, str | int | float):
126
+ # This is likely that the raw value is e.g. `"hello"` which we
127
+ # Should really be parsed as '"hello"' in Python - but if we parse
128
+ # it as JSON it'll turn into just 'hello'. So we skip it.
129
+ continue
130
+ new_data[field_name] = pre_parsed
131
+ assert new_data.keys() == data.keys()
132
+ return new_data
133
+
134
+ model_config = ConfigDict(
135
+ arbitrary_types_allowed=True,
136
+ )
137
+
138
+ @classmethod
139
+ def func_metadata(
140
+ cls, func: Callable[..., Any], skip_names: Sequence[str] = ()
141
+ ) -> "FuncMetadata":
142
+ """Given a function, return metadata including a pydantic model representing its
143
+ signature.
144
+
145
+ The use case for this is
146
+ ```
147
+ meta = func_to_pyd(func)
148
+ validated_args = meta.arg_model.model_validate(some_raw_data_dict)
149
+ return func(**validated_args.model_dump_one_level())
150
+ ```
151
+
152
+ **critically** it also provides pre-parse helper to attempt to parse things from
153
+ JSON.
154
+
155
+ Args:
156
+ func: The function to convert to a pydantic model
157
+ skip_names: A list of parameter names to skip. These will not be included in
158
+ the model.
159
+ Returns:
160
+ A pydantic model representing the function's signature.
161
+ """
162
+ sig = _get_typed_signature(func)
163
+ params = sig.parameters
164
+ dynamic_pydantic_model_params: dict[str, Any] = {}
165
+ globalns = getattr(func, "__globals__", {})
166
+ for param in params.values():
167
+ if param.name.startswith("_"):
168
+ raise InvalidSignature(
169
+ f"Parameter {param.name} of {func.__name__} cannot start with '_'"
170
+ )
171
+ if param.name in skip_names:
172
+ continue
173
+ annotation = param.annotation
174
+
175
+ # `x: None` / `x: None = None`
176
+ if annotation is None:
177
+ annotation = Annotated[
178
+ None,
179
+ Field(
180
+ default=param.default
181
+ if param.default is not inspect.Parameter.empty
182
+ else PydanticUndefined
183
+ ),
184
+ ]
185
+
186
+ # Untyped field
187
+ if annotation is inspect.Parameter.empty:
188
+ annotation = Annotated[
189
+ Any,
190
+ Field(),
191
+ # 🤷
192
+ WithJsonSchema({"title": param.name, "type": "string"}),
193
+ ]
194
+
195
+ field_info = FieldInfo.from_annotated_attribute(
196
+ _get_typed_annotation(annotation, globalns),
197
+ param.default
198
+ if param.default is not inspect.Parameter.empty
199
+ else PydanticUndefined,
200
+ )
201
+ dynamic_pydantic_model_params[param.name] = (
202
+ field_info.annotation,
203
+ field_info,
204
+ )
205
+ continue
206
+
207
+ arguments_model = create_model(
208
+ f"{func.__name__}Arguments",
209
+ **dynamic_pydantic_model_params,
210
+ __base__=ArgModelBase,
211
+ )
212
+ resp = FuncMetadata(arg_model=arguments_model)
213
+ return resp