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.
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/PKG-INFO +2 -2
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/pyproject.toml +2 -2
- universal_mcp-0.1.23/src/tests/test_applications.py +74 -0
- universal_mcp-0.1.23/src/tests/test_tool.py +476 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/applications/__init__.py +5 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/applications/application.py +34 -15
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/cli.py +2 -1
- universal_mcp-0.1.23/src/universal_mcp/client/__main__.py +30 -0
- universal_mcp-0.1.23/src/universal_mcp/client/agent.py +96 -0
- universal_mcp-0.1.23/src/universal_mcp/client/client.py +198 -0
- universal_mcp-0.1.23/src/universal_mcp/client/oauth.py +114 -0
- universal_mcp-0.1.23/src/universal_mcp/client/token_store.py +32 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/config.py +62 -3
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/integrations/integration.py +2 -2
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/servers/server.py +7 -28
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/tools/adapters.py +34 -3
- universal_mcp-0.1.23/src/universal_mcp/tools/func_metadata.py +288 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/tools/tools.py +11 -4
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/agentr.py +29 -1
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/docstring_parser.py +34 -52
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/openapi/api_splitter.py +11 -6
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/openapi/openapi.py +11 -12
- universal_mcp-0.1.23/src/universal_mcp/utils/testing.py +31 -0
- universal_mcp-0.1.22rc1/src/tests/test_applications.py +0 -92
- universal_mcp-0.1.22rc1/src/tests/test_tool.py +0 -325
- universal_mcp-0.1.22rc1/src/universal_mcp/tools/func_metadata.py +0 -211
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/.gitignore +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/LICENSE +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/README.md +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/__init__.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/conftest.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/test_api_generator.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/test_api_integration.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/test_localserver.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/test_stores.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/test_tool_manager.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/tests/test_zenquotes.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/__init__.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/analytics.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/applications/README.md +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/exceptions.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/integrations/README.md +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/integrations/__init__.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/logger.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/py.typed +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/servers/README.md +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/servers/__init__.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/stores/README.md +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/stores/__init__.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/stores/store.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/tools/README.md +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/tools/__init__.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/tools/manager.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/__init__.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/common.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/installation.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/openapi/__inti__.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/openapi/api_generator.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/openapi/docgen.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/openapi/preprocessor.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/openapi/readme.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/singleton.py +0 -0
- {universal_mcp-0.1.22rc1 → universal_mcp-0.1.23}/src/universal_mcp/utils/templates/README.md.j2 +0 -0
- {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.
|
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.
|
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.
|
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.
|
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
|