universal-mcp 0.1.22rc1__tar.gz → 0.1.23__tar.gz

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 (64) hide show
  1. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/PKG-INFO +2 -2
  2. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/pyproject.toml +2 -2
  3. universal_mcp-0.1.23/src/tests/test_applications.py +74 -0
  4. universal_mcp-0.1.23/src/tests/test_tool.py +476 -0
  5. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/applications/__init__.py +5 -0
  6. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/applications/application.py +34 -15
  7. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/cli.py +2 -1
  8. universal_mcp-0.1.23/src/universal_mcp/client/__main__.py +30 -0
  9. universal_mcp-0.1.23/src/universal_mcp/client/agent.py +96 -0
  10. universal_mcp-0.1.23/src/universal_mcp/client/client.py +198 -0
  11. universal_mcp-0.1.23/src/universal_mcp/client/oauth.py +114 -0
  12. universal_mcp-0.1.23/src/universal_mcp/client/token_store.py +32 -0
  13. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/config.py +62 -3
  14. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/integrations/integration.py +2 -2
  15. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/servers/server.py +7 -28
  16. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/tools/adapters.py +34 -3
  17. universal_mcp-0.1.23/src/universal_mcp/tools/func_metadata.py +288 -0
  18. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/tools/tools.py +11 -4
  19. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/agentr.py +29 -1
  20. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/docstring_parser.py +34 -52
  21. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/openapi/api_splitter.py +11 -6
  22. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/openapi/openapi.py +11 -12
  23. universal_mcp-0.1.23/src/universal_mcp/utils/testing.py +31 -0
  24. universal_mcp-0.1.22rc1/src/tests/test_applications.py +0 -92
  25. universal_mcp-0.1.22rc1/src/tests/test_tool.py +0 -325
  26. universal_mcp-0.1.22rc1/src/universal_mcp/tools/func_metadata.py +0 -211
  27. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/.gitignore +0 -0
  28. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/LICENSE +0 -0
  29. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/README.md +0 -0
  30. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/__init__.py +0 -0
  31. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/conftest.py +0 -0
  32. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/test_api_generator.py +0 -0
  33. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/test_api_integration.py +0 -0
  34. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/test_localserver.py +0 -0
  35. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/test_stores.py +0 -0
  36. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/test_tool_manager.py +0 -0
  37. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/test_zenquotes.py +0 -0
  38. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/__init__.py +0 -0
  39. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/analytics.py +0 -0
  40. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/applications/README.md +0 -0
  41. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/exceptions.py +0 -0
  42. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/integrations/README.md +0 -0
  43. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/integrations/__init__.py +0 -0
  44. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/logger.py +0 -0
  45. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/py.typed +0 -0
  46. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/servers/README.md +0 -0
  47. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/servers/__init__.py +0 -0
  48. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/stores/README.md +0 -0
  49. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/stores/__init__.py +0 -0
  50. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/stores/store.py +0 -0
  51. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/tools/README.md +0 -0
  52. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/tools/__init__.py +0 -0
  53. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/tools/manager.py +0 -0
  54. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/__init__.py +0 -0
  55. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/common.py +0 -0
  56. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/installation.py +0 -0
  57. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/openapi/__inti__.py +0 -0
  58. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/openapi/api_generator.py +0 -0
  59. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/openapi/docgen.py +0 -0
  60. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/openapi/preprocessor.py +0 -0
  61. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/openapi/readme.py +0 -0
  62. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/singleton.py +0 -0
  63. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/templates/README.md.j2 +0 -0
  64. {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/templates/api_client.py.j2 +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: universal-mcp
3
- Version: 0.1.22rc1
3
+ Version: 0.1.23
4
4
  Summary: Universal MCP acts as a middle ware for your API applications. It can store your credentials, authorize, enable disable apps on the fly and much more.
5
5
  Author-email: Manoj Bajaj <manojbajaj95@gmail.com>
6
6
  License: MIT
@@ -15,7 +15,7 @@ Requires-Dist: keyring>=25.6.0
15
15
  Requires-Dist: langchain-mcp-adapters>=0.0.3
16
16
  Requires-Dist: litellm>=1.30.7
17
17
  Requires-Dist: loguru>=0.7.3
18
- Requires-Dist: mcp>=1.9.0
18
+ Requires-Dist: mcp>=1.9.3
19
19
  Requires-Dist: posthog>=3.24.0
20
20
  Requires-Dist: pydantic-settings>=2.8.1
21
21
  Requires-Dist: pydantic>=2.11.1
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "universal-mcp"
7
- version = "0.1.22-rc1"
7
+ version = "0.1.23"
8
8
  description = "Universal MCP acts as a middle ware for your API applications. It can store your credentials, authorize, enable disable apps on the fly and much more."
9
9
  readme = "README.md"
10
10
  authors = [
@@ -22,7 +22,7 @@ dependencies = [
22
22
  "langchain-mcp-adapters>=0.0.3",
23
23
  "litellm>=1.30.7",
24
24
  "loguru>=0.7.3",
25
- "mcp>=1.9.0",
25
+ "mcp>=1.9.3",
26
26
  "posthog>=3.24.0",
27
27
  "pydantic>=2.11.1",
28
28
  "pydantic-settings>=2.8.1",
@@ -0,0 +1,74 @@
1
+ import pytest
2
+
3
+ from universal_mcp.applications import app_from_slug
4
+ from universal_mcp.utils.testing import check_application_instance
5
+
6
+ ALL_APPS = [
7
+ "ahrefs",
8
+ "airtable",
9
+ "aws-s3",
10
+ "apollo",
11
+ "asana",
12
+ "box",
13
+ "braze",
14
+ "cal-com-v2",
15
+ "confluence",
16
+ "calendly",
17
+ "canva",
18
+ # "clickup",
19
+ "coda",
20
+ "crustdata",
21
+ "e2b",
22
+ "elevenlabs",
23
+ "exa",
24
+ "falai",
25
+ "figma",
26
+ "firecrawl",
27
+ "github",
28
+ "gong",
29
+ "google-calendar",
30
+ "google-docs",
31
+ "google-drive",
32
+ "google-gemini",
33
+ "google-mail",
34
+ "google-sheet",
35
+ "hashnode",
36
+ "heygen",
37
+ "hubspot",
38
+ "jira",
39
+ "klaviyo",
40
+ "mailchimp",
41
+ "markitdown",
42
+ "miro",
43
+ "ms-teams",
44
+ "neon",
45
+ "notion",
46
+ "perplexity",
47
+ "pipedrive",
48
+ "posthog",
49
+ "reddit",
50
+ "replicate",
51
+ "resend",
52
+ "retell",
53
+ "rocketlane",
54
+ "serpapi",
55
+ "sharepoint",
56
+ "shopify",
57
+ "shortcut",
58
+ "spotify",
59
+ "supabase",
60
+ "tavily",
61
+ "trello",
62
+ "unipile",
63
+ "whatsapp-business",
64
+ "wrike",
65
+ "youtube",
66
+ "zenquotes",
67
+ ]
68
+
69
+
70
+ @pytest.mark.parametrize("app_name", ALL_APPS)
71
+ def test_application(app_name):
72
+ app = app_from_slug(app_name)
73
+ app_instance = app(integration=None)
74
+ check_application_instance(app_instance, app_name)
@@ -0,0 +1,476 @@
1
+ import inspect
2
+ from typing import Annotated
3
+
4
+ from pydantic import Field
5
+
6
+ from universal_mcp.tools.func_metadata import FuncMetadata
7
+ from universal_mcp.tools.tools import Tool
8
+ from universal_mcp.utils.docstring_parser import parse_docstring # Assuming this is the updated one
9
+
10
+
11
+ def test_func_metadata_annotated():
12
+ def func(a: Annotated[int, Field(title="First integer")], b: int):
13
+ """Test function with annotated and typed args"""
14
+ return a + b
15
+
16
+ meta = FuncMetadata.func_metadata(func)
17
+ schema = meta.arg_model.model_json_schema()
18
+ assert schema["properties"]["a"]["type"] == "integer"
19
+ assert schema["properties"]["a"]["title"] == "First integer" # From Annotated
20
+ assert schema["properties"]["b"]["type"] == "integer"
21
+ assert schema["properties"]["b"]["title"] == "b" # Pydantic's default title is field name
22
+ assert "a" in schema["required"]
23
+ assert "b" in schema["required"]
24
+
25
+
26
+ def test_func_metadata_no_annotated():
27
+ def func(a: int, b: int):
28
+ """Test function with no annotated args
29
+
30
+ Args:
31
+ a: The first integer
32
+ b: The second integer
33
+ """
34
+ return a + b
35
+
36
+ raw_doc = inspect.getdoc(func)
37
+ parsed_doc = parse_docstring(raw_doc)
38
+ arg_descriptions_for_func_metadata = parsed_doc.get("args", {})
39
+
40
+ meta = FuncMetadata.func_metadata(func, arg_description=arg_descriptions_for_func_metadata)
41
+ schema = meta.arg_model.model_json_schema()
42
+
43
+ assert schema["properties"]["a"]["type"] == "integer"
44
+ # Title might be "a" by default, description "The first integer"
45
+ assert schema["properties"]["a"].get("description") == "The first integer"
46
+ assert schema["properties"]["a"].get("title") == "a"
47
+ assert schema["properties"]["b"]["type"] == "integer"
48
+ assert schema["properties"]["b"].get("description") == "The second integer"
49
+ assert schema["properties"]["b"].get("title") == "b"
50
+ assert "a" in schema["required"]
51
+ assert "b" in schema["required"]
52
+
53
+
54
+ def test_func_metadata_no_args():
55
+ def func():
56
+ """Test function with no args"""
57
+ return 42
58
+
59
+ meta = FuncMetadata.func_metadata(func)
60
+ schema = meta.arg_model.model_json_schema()
61
+ assert schema["properties"] == {}
62
+ assert schema.get("required") is None or len(schema.get("required", [])) == 0
63
+
64
+
65
+ def test_func_metadata_untyped_no_docstring_type():
66
+ def func(a, b=10):
67
+ """Test function with untyped args
68
+ Args:
69
+ a: Untyped a
70
+ b: Untyped b with default
71
+ """ # No type_str in docstring
72
+ return a + b
73
+
74
+ raw_doc = inspect.getdoc(func)
75
+ parsed_doc = parse_docstring(raw_doc)
76
+ arg_descriptions_for_func_metadata = parsed_doc.get("args", {})
77
+
78
+ meta = FuncMetadata.func_metadata(func, arg_description=arg_descriptions_for_func_metadata)
79
+ schema = meta.arg_model.model_json_schema()
80
+
81
+ assert schema["properties"]["a"]["type"] == "string" # Default schema type for Any
82
+ assert schema["properties"]["a"].get("description") == "Untyped a"
83
+ assert schema["properties"]["b"]["type"] == "string"
84
+ assert schema["properties"]["b"].get("description") == "Untyped b with default"
85
+ assert schema["properties"]["b"]["default"] == 10
86
+ assert "a" in schema["required"]
87
+ assert "b" not in schema.get("required", [])
88
+
89
+
90
+ def test_func_metadata_required():
91
+ def func(a: int, b: str, c: float = 1.0):
92
+ """Test function with required and optional args
93
+ Args:
94
+ a: An int
95
+ b: A string
96
+ c: A float
97
+ """
98
+ return f"{a} {b} {c}"
99
+
100
+ raw_doc = inspect.getdoc(func)
101
+ parsed_doc = parse_docstring(raw_doc)
102
+ arg_descriptions_for_func_metadata = parsed_doc.get("args", {})
103
+
104
+ meta = FuncMetadata.func_metadata(func, arg_description=arg_descriptions_for_func_metadata)
105
+ schema = meta.arg_model.model_json_schema()
106
+
107
+ assert schema["properties"]["a"]["type"] == "integer"
108
+ assert schema["properties"]["a"].get("description") == "An int"
109
+ assert schema["properties"]["b"]["type"] == "string"
110
+ assert schema["properties"]["b"].get("description") == "A string"
111
+ assert schema["properties"]["c"]["type"] == "number"
112
+ assert schema["properties"]["c"].get("description") == "A float"
113
+ assert schema["properties"]["c"]["default"] == 1.0
114
+ assert "a" in schema["required"]
115
+ assert "b" in schema["required"]
116
+ assert "c" not in schema.get("required", [])
117
+
118
+
119
+ def test_func_metadata_none_type():
120
+ def func(a: None = None):
121
+ """Test function with None type
122
+ Args:
123
+ a: A None value
124
+ """
125
+ return a
126
+
127
+ raw_doc = inspect.getdoc(func)
128
+ parsed_doc = parse_docstring(raw_doc)
129
+ arg_descriptions_for_func_metadata = parsed_doc.get("args", {})
130
+
131
+ meta = FuncMetadata.func_metadata(func, arg_description=arg_descriptions_for_func_metadata)
132
+ schema = meta.arg_model.model_json_schema()
133
+ assert schema["properties"]["a"]["type"] == "null"
134
+ assert schema["properties"]["a"].get("description") == "A None value"
135
+ assert schema["properties"]["a"]["default"] is None
136
+
137
+
138
+ def test_parse_docstring_empty_none():
139
+ docstring = None
140
+ expected = {"summary": "", "args": {}, "returns": "", "raises": {}, "tags": []}
141
+ assert parse_docstring(docstring) == expected
142
+
143
+
144
+ def test_parse_docstring_empty_string():
145
+ docstring = ""
146
+ expected = {"summary": "", "args": {}, "returns": "", "raises": {}, "tags": []}
147
+ assert parse_docstring(docstring) == expected
148
+
149
+
150
+ def test_parse_docstring_whitespace_string():
151
+ docstring = " \n "
152
+ expected = {"summary": "", "args": {}, "returns": "", "raises": {}, "tags": []}
153
+ assert parse_docstring(docstring) == expected
154
+
155
+
156
+ def test_parse_docstring_summary_only():
157
+ docstring = "This is a short summary.\nIt spans multiple lines."
158
+ expected = {
159
+ "summary": "This is a short summary. It spans multiple lines.",
160
+ "args": {},
161
+ "returns": "",
162
+ "raises": {},
163
+ "tags": [],
164
+ }
165
+ assert parse_docstring(docstring) == expected
166
+
167
+
168
+ def test_parse_docstring_summary_and_args_no_type_str():
169
+ docstring = "Function to add two numbers.\n\nArgs:\n a: The first number.\n b: The second number."
170
+ expected = {
171
+ "summary": "Function to add two numbers.",
172
+ "args": {
173
+ "a": {"description": "The first number.", "type_str": None},
174
+ "b": {"description": "The second number.", "type_str": None},
175
+ },
176
+ "returns": "",
177
+ "raises": {},
178
+ "tags": [],
179
+ }
180
+ assert parse_docstring(docstring) == expected
181
+
182
+
183
+ def test_parse_docstring_summary_and_returns():
184
+ docstring = "Calculates the sum.\n\nReturns:\n The sum of a and b."
185
+ expected = {
186
+ "summary": "Calculates the sum.",
187
+ "args": {},
188
+ "returns": "The sum of a and b.",
189
+ "raises": {},
190
+ "tags": [],
191
+ }
192
+ assert parse_docstring(docstring) == expected
193
+
194
+
195
+ def test_parse_docstring_summary_and_raises():
196
+ docstring = "Divides two numbers.\n\nRaises:\n ZeroDivisionError: If the denominator is zero."
197
+ expected = {
198
+ "summary": "Divides two numbers.",
199
+ "args": {},
200
+ "returns": "",
201
+ "raises": {"ZeroDivisionError": "If the denominator is zero."},
202
+ "tags": [],
203
+ }
204
+ assert parse_docstring(docstring) == expected
205
+
206
+
207
+ def test_parse_docstring_summary_and_tags():
208
+ docstring = "Processes data.\n\nTags:\n data, processing, important"
209
+ expected = {
210
+ "summary": "Processes data.",
211
+ "args": {},
212
+ "returns": "",
213
+ "raises": {},
214
+ "tags": ["data", "processing", "important"],
215
+ }
216
+ assert parse_docstring(docstring) == expected
217
+
218
+
219
+ def test_parse_docstring_multiple_sections_single_line():
220
+ docstring = """
221
+ Performs a basic operation.
222
+
223
+ Args:
224
+ input: Input value.
225
+ Returns:
226
+ Output value.
227
+ Raises:
228
+ Exception: If something goes wrong.
229
+ Tags:
230
+ basic, test
231
+ """
232
+ expected = {
233
+ "summary": "Performs a basic operation.",
234
+ "args": {"input": {"description": "Input value.", "type_str": None}},
235
+ "returns": "Output value.",
236
+ "raises": {"Exception": "If something goes wrong."},
237
+ "tags": ["basic", "test"],
238
+ }
239
+ assert parse_docstring(docstring) == expected
240
+
241
+
242
+ def test_parse_docstring_args_multi_line():
243
+ docstring = """
244
+ Processes complex input.
245
+
246
+ Args:
247
+ config: Configuration object.
248
+ It contains settings for the processing job,
249
+ including connection details and parameters.
250
+ """
251
+ expected = {
252
+ "summary": "Processes complex input.",
253
+ "args": {
254
+ "config": {
255
+ "description": "Configuration object. It contains settings for the processing job, including connection details and parameters.",
256
+ "type_str": None,
257
+ }
258
+ },
259
+ "returns": "",
260
+ "raises": {},
261
+ "tags": [],
262
+ }
263
+ assert parse_docstring(docstring) == expected
264
+
265
+
266
+ def test_parse_docstring_returns_multi_line():
267
+ docstring = """
268
+ Fetches detailed information.
269
+
270
+ Returns:
271
+ A dictionary containing comprehensive details about the fetched data,
272
+ including timestamps, source, and processing status. This is a detailed response.
273
+ """
274
+ expected = {
275
+ "summary": "Fetches detailed information.",
276
+ "args": {},
277
+ "returns": "A dictionary containing comprehensive details about the fetched data, including timestamps, source, and processing status. This is a detailed response.",
278
+ "raises": {},
279
+ "tags": [],
280
+ }
281
+ assert parse_docstring(docstring) == expected
282
+
283
+
284
+ def test_parse_docstring_raises_multi_line():
285
+ docstring = """
286
+ Performs a critical action.
287
+
288
+ Raises:
289
+ CriticalError: This error indicates a major failure
290
+ during the critical action execution.
291
+ Further details are logged separately.
292
+ """
293
+ expected = {
294
+ "summary": "Performs a critical action.",
295
+ "args": {},
296
+ "returns": "",
297
+ "raises": {
298
+ "CriticalError": "This error indicates a major failure during the critical action execution. Further details are logged separately."
299
+ },
300
+ "tags": [],
301
+ }
302
+ assert parse_docstring(docstring) == expected
303
+
304
+
305
+ def test_parse_docstring_no_summary():
306
+ docstring = "Args:\n data: The data to process.\nReturns:\n Processed data."
307
+ expected = {
308
+ "summary": "",
309
+ "args": {"data": {"description": "The data to process.", "type_str": None}},
310
+ "returns": "Processed data.",
311
+ "raises": {},
312
+ "tags": [],
313
+ }
314
+ assert parse_docstring(docstring) == expected
315
+
316
+
317
+ def test_parse_docstring_with_type_str():
318
+ docstring = """
319
+ Processes an item.
320
+
321
+ Args:
322
+ item_id (int): The ID of the item.
323
+ name (str): The name of the item.
324
+ details (object): Optional details.
325
+ """
326
+ expected = {
327
+ "summary": "Processes an item.",
328
+ "args": {
329
+ "item_id": {"description": "The ID of the item.", "type_str": "int"},
330
+ "name": {"description": "The name of the item.", "type_str": "str"},
331
+ "details": {"description": "Optional details.", "type_str": "object"},
332
+ },
333
+ "returns": "",
334
+ "raises": {},
335
+ "tags": [],
336
+ }
337
+ assert parse_docstring(docstring) == expected
338
+
339
+
340
+ def test_parse_docstring_with_mixed_type_str_and_none():
341
+ docstring = """
342
+ Handles configuration.
343
+
344
+ Args:
345
+ path (string): Path to config file.
346
+ strict_mode: Enable strict mode. (No type in docstring)
347
+ retries (integer): Number of retries.
348
+ """
349
+ expected = {
350
+ "summary": "Handles configuration.",
351
+ "args": {
352
+ "path": {"description": "Path to config file.", "type_str": "string"},
353
+ "strict_mode": {
354
+ "description": "Enable strict mode. (No type in docstring)",
355
+ "type_str": None,
356
+ }, # Corrected description
357
+ "retries": {"description": "Number of retries.", "type_str": "integer"},
358
+ },
359
+ "returns": "",
360
+ "raises": {},
361
+ "tags": [],
362
+ }
363
+ assert parse_docstring(docstring) == expected
364
+
365
+
366
+ def test_parse_docstring_type_str_with_spaces_in_type():
367
+ docstring = """
368
+ Args:
369
+ complex_type (list of strings): A list containing strings.
370
+ """
371
+ expected = {
372
+ "summary": "",
373
+ "args": {
374
+ "complex_type": {"description": "A list containing strings.", "type_str": "list of strings"},
375
+ },
376
+ "returns": "",
377
+ "raises": {},
378
+ "tags": [],
379
+ }
380
+ assert parse_docstring(docstring) == expected
381
+
382
+
383
+ def test_func_metadata_untyped_with_docstring_type():
384
+ def func(name, age, data=None):
385
+ """Test function with untyped args but types in docstring
386
+ Args:
387
+ name (str): The name.
388
+ age (int): The age.
389
+ data (list): Optional data.
390
+ """
391
+ return f"{name} is {age}"
392
+
393
+ raw_doc = inspect.getdoc(func)
394
+ parsed_doc = parse_docstring(raw_doc)
395
+ arg_descriptions_for_func_metadata = parsed_doc.get("args", {})
396
+
397
+ meta = FuncMetadata.func_metadata(func, arg_description=arg_descriptions_for_func_metadata)
398
+ schema = meta.arg_model.model_json_schema()
399
+
400
+ assert schema["properties"]["name"]["type"] == "string"
401
+ assert schema["properties"]["name"].get("description") == "The name."
402
+ assert schema["properties"]["age"]["type"] == "integer"
403
+ assert schema["properties"]["age"].get("description") == "The age."
404
+ assert schema["properties"]["data"]["type"] == "array"
405
+ assert schema["properties"]["data"].get("description") == "Optional data."
406
+ assert schema["properties"]["data"]["default"] is None
407
+
408
+ assert "name" in schema["required"]
409
+ assert "age" in schema["required"]
410
+ assert "data" not in schema.get("required", [])
411
+
412
+
413
+ def test_func_metadata_mixed_hints_and_docstring_types():
414
+ def func(item_id: int, quantity, details: str = "default details"):
415
+ """
416
+ Processes an order.
417
+
418
+ Args:
419
+ item_id: The ID of the item (already typed).
420
+ quantity (integer): The number of items.
421
+ details (string): Order details.
422
+ """
423
+ return item_id * quantity
424
+
425
+ raw_doc = inspect.getdoc(func)
426
+ parsed_doc = parse_docstring(raw_doc)
427
+ arg_descriptions_for_func_metadata = parsed_doc.get("args", {})
428
+
429
+ meta = FuncMetadata.func_metadata(func, arg_description=arg_descriptions_for_func_metadata)
430
+ schema = meta.arg_model.model_json_schema()
431
+
432
+ assert schema["properties"]["item_id"]["type"] == "integer"
433
+ assert schema["properties"]["item_id"].get("description") == "The ID of the item (already typed)."
434
+
435
+ assert schema["properties"]["quantity"]["type"] == "integer"
436
+ assert schema["properties"]["quantity"].get("description") == "The number of items."
437
+
438
+ assert schema["properties"]["details"]["type"] == "string"
439
+ assert schema["properties"]["details"].get("description") == "Order details."
440
+ assert schema["properties"]["details"]["default"] == "default details"
441
+
442
+ assert "item_id" in schema["required"]
443
+ assert "quantity" in schema["required"]
444
+ assert "details" not in schema.get("required", [])
445
+
446
+
447
+ def test_tool_from_function_with_docstring_types():
448
+ """Test the full Tool.from_function flow with docstring types"""
449
+
450
+ def my_tool(name, age=30):
451
+ """
452
+ A simple tool.
453
+
454
+ Args:
455
+ name (string): The name to use.
456
+ age (int): The age value.
457
+ Returns:
458
+ A greeting string.
459
+ """
460
+ return f"Hello {name}, you are {age}."
461
+
462
+ tool_instance = Tool.from_function(my_tool)
463
+
464
+ assert tool_instance.name == "my_tool"
465
+ assert tool_instance.description == "A simple tool."
466
+ assert tool_instance.args_description == {"name": "The name to use.", "age": "The age value."}
467
+ assert tool_instance.returns_description == "A greeting string."
468
+
469
+ meta_schema = tool_instance.fn_metadata.arg_model.model_json_schema()
470
+ assert meta_schema["properties"]["name"]["type"] == "string"
471
+ assert meta_schema["properties"]["name"].get("description") == "The name to use."
472
+ assert meta_schema["properties"]["age"]["type"] == "integer"
473
+ assert meta_schema["properties"]["age"].get("description") == "The age value."
474
+ assert meta_schema["properties"]["age"]["default"] == 30
475
+ assert "name" in meta_schema["required"]
476
+ assert "age" not in meta_schema.get("required", [])
@@ -30,6 +30,8 @@ sys.path.append(str(UNIVERSAL_MCP_HOME))
30
30
  # Name are in the format of "app-name", eg, google-calendar
31
31
  # Class name is NameApp, eg, GoogleCalendarApp
32
32
 
33
+ app_cache: dict[str, type[BaseApplication]] = {}
34
+
33
35
 
34
36
  def _install_or_upgrade_package(package_name: str, repository_path: str):
35
37
  """
@@ -71,6 +73,8 @@ def app_from_slug(slug: str):
71
73
  Dynamically resolve and return the application class for the given slug.
72
74
  Attempts installation from GitHub if the package is not found locally.
73
75
  """
76
+ if slug in app_cache:
77
+ return app_cache[slug]
74
78
  class_name = get_default_class_name(slug)
75
79
  module_path = get_default_module_path(slug)
76
80
  package_name = get_default_package_name(slug)
@@ -81,6 +85,7 @@ def app_from_slug(slug: str):
81
85
  module = importlib.import_module(module_path)
82
86
  class_ = getattr(module, class_name)
83
87
  logger.debug(f"Loaded class '{class_}' from module '{module_path}'")
88
+ app_cache[slug] = class_
84
89
  return class_
85
90
  except ModuleNotFoundError as e:
86
91
  raise ModuleNotFoundError(f"Package '{module_path}' not found locally. Please install it first.") from e