universal-mcp 0.1.7rc1__py3-none-any.whl → 0.1.8__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 (61) hide show
  1. universal_mcp/__init__.py +0 -2
  2. universal_mcp/analytics.py +75 -0
  3. universal_mcp/applications/ahrefs/README.md +76 -0
  4. universal_mcp/applications/ahrefs/app.py +2291 -0
  5. universal_mcp/applications/application.py +95 -5
  6. universal_mcp/applications/calendly/README.md +78 -0
  7. universal_mcp/applications/calendly/__init__.py +0 -0
  8. universal_mcp/applications/calendly/app.py +1195 -0
  9. universal_mcp/applications/coda/README.md +133 -0
  10. universal_mcp/applications/coda/__init__.py +0 -0
  11. universal_mcp/applications/coda/app.py +3671 -0
  12. universal_mcp/applications/e2b/app.py +14 -28
  13. universal_mcp/applications/figma/README.md +74 -0
  14. universal_mcp/applications/figma/__init__.py +0 -0
  15. universal_mcp/applications/figma/app.py +1261 -0
  16. universal_mcp/applications/firecrawl/app.py +38 -35
  17. universal_mcp/applications/github/app.py +127 -85
  18. universal_mcp/applications/google_calendar/app.py +62 -138
  19. universal_mcp/applications/google_docs/app.py +47 -52
  20. universal_mcp/applications/google_drive/app.py +119 -113
  21. universal_mcp/applications/google_mail/app.py +124 -50
  22. universal_mcp/applications/google_sheet/app.py +89 -91
  23. universal_mcp/applications/markitdown/app.py +9 -8
  24. universal_mcp/applications/notion/app.py +254 -134
  25. universal_mcp/applications/perplexity/app.py +13 -41
  26. universal_mcp/applications/reddit/app.py +94 -85
  27. universal_mcp/applications/resend/app.py +12 -13
  28. universal_mcp/applications/{serp → serpapi}/app.py +14 -25
  29. universal_mcp/applications/tavily/app.py +11 -18
  30. universal_mcp/applications/wrike/README.md +71 -0
  31. universal_mcp/applications/wrike/__init__.py +0 -0
  32. universal_mcp/applications/wrike/app.py +1372 -0
  33. universal_mcp/applications/youtube/README.md +82 -0
  34. universal_mcp/applications/youtube/__init__.py +0 -0
  35. universal_mcp/applications/youtube/app.py +1428 -0
  36. universal_mcp/applications/zenquotes/app.py +12 -2
  37. universal_mcp/exceptions.py +9 -2
  38. universal_mcp/integrations/__init__.py +24 -1
  39. universal_mcp/integrations/agentr.py +27 -4
  40. universal_mcp/integrations/integration.py +146 -32
  41. universal_mcp/logger.py +3 -56
  42. universal_mcp/servers/__init__.py +6 -14
  43. universal_mcp/servers/server.py +201 -146
  44. universal_mcp/stores/__init__.py +7 -2
  45. universal_mcp/stores/store.py +103 -40
  46. universal_mcp/tools/__init__.py +3 -0
  47. universal_mcp/tools/adapters.py +43 -0
  48. universal_mcp/tools/func_metadata.py +213 -0
  49. universal_mcp/tools/tools.py +342 -0
  50. universal_mcp/utils/docgen.py +325 -119
  51. universal_mcp/utils/docstring_parser.py +179 -0
  52. universal_mcp/utils/dump_app_tools.py +33 -23
  53. universal_mcp/utils/installation.py +201 -10
  54. universal_mcp/utils/openapi.py +229 -46
  55. {universal_mcp-0.1.7rc1.dist-info → universal_mcp-0.1.8.dist-info}/METADATA +9 -5
  56. universal_mcp-0.1.8.dist-info/RECORD +81 -0
  57. universal_mcp-0.1.7rc1.dist-info/RECORD +0 -58
  58. /universal_mcp/{utils/bridge.py → applications/ahrefs/__init__.py} +0 -0
  59. /universal_mcp/applications/{serp → serpapi}/README.md +0 -0
  60. {universal_mcp-0.1.7rc1.dist-info → universal_mcp-0.1.8.dist-info}/WHEEL +0 -0
  61. {universal_mcp-0.1.7rc1.dist-info → universal_mcp-0.1.8.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,179 @@
1
+ import re
2
+ from typing import Any
3
+
4
+
5
+ def parse_docstring(docstring: str | None) -> dict[str, Any]:
6
+ """
7
+ Parses a standard Python docstring into summary, args, returns, raises, and tags.
8
+
9
+ Args:
10
+ docstring: The docstring to parse.
11
+
12
+ Returns:
13
+ A dictionary with keys 'summary', 'args', 'returns', 'raises', 'tags'.
14
+ 'args' is a dict mapping arg names to descriptions.
15
+ 'raises' is a dict mapping exception type names to descriptions.
16
+ 'tags' is a list of strings extracted from the 'Tags:' section, comma-separated.
17
+ """
18
+ if not docstring:
19
+ return {"summary": "", "args": {}, "returns": "", "raises": {}, "tags": []}
20
+
21
+ lines = docstring.strip().splitlines()
22
+ if not lines:
23
+ return {"summary": "", "args": {}, "returns": "", "raises": {}, "tags": []}
24
+
25
+ summary = lines[0].strip()
26
+ args = {}
27
+ returns = ""
28
+ raises = {}
29
+ tags: list[str] = [] # Final list of parsed tags
30
+ current_section = None
31
+ current_key = None
32
+ current_desc_lines = [] # Accumulator for multi-line descriptions/tag content
33
+ key_pattern = re.compile(r"^\s*([\w\.]+)\s*(?:\(.*\))?:\s*(.*)")
34
+
35
+ def finalize_current_item():
36
+ """Helper function to finalize the currently parsed item."""
37
+ nonlocal returns, tags # Allow modification of outer scope variables
38
+ desc = " ".join(current_desc_lines).strip()
39
+ if current_section == "args" and current_key:
40
+ args[current_key] = desc
41
+ elif current_section == "raises" and current_key:
42
+ raises[current_key] = desc
43
+ elif current_section == "returns":
44
+ returns = desc
45
+ # SIM102 applied: Combine nested if
46
+ elif current_section == "tags" and desc: # Only process if there's content
47
+ tags = [tag.strip() for tag in desc.split(",") if tag.strip()]
48
+
49
+ # B007 applied: Rename unused loop variable i to _
50
+ for _, line in enumerate(lines[1:]):
51
+ stripped_line = line.strip()
52
+ original_indentation = len(line) - len(line.lstrip(" "))
53
+
54
+ section_line = stripped_line.lower()
55
+ is_new_section_header = False
56
+ new_section_type = None
57
+ header_content = ""
58
+
59
+ if section_line in ("args:", "arguments:", "parameters:"):
60
+ new_section_type = "args"
61
+ is_new_section_header = True
62
+ elif section_line in ("returns:", "yields:"):
63
+ new_section_type = "returns"
64
+ is_new_section_header = True
65
+ elif section_line.startswith(("raises ", "raises:", "errors:", "exceptions:")):
66
+ new_section_type = "raises"
67
+ is_new_section_header = True
68
+ elif section_line.startswith(
69
+ ("tags:", "tags")
70
+ ): # Match "Tags:" or "Tags" potentially followed by content
71
+ new_section_type = "tags"
72
+ is_new_section_header = True
73
+ if ":" in stripped_line:
74
+ header_content = stripped_line.split(":", 1)[1].strip()
75
+ elif section_line.endswith(":") and section_line[:-1] in (
76
+ "attributes",
77
+ "see also",
78
+ "example",
79
+ "examples",
80
+ "notes",
81
+ ):
82
+ new_section_type = "other"
83
+ is_new_section_header = True
84
+
85
+ finalize_previous = False
86
+ if is_new_section_header:
87
+ finalize_previous = True
88
+ elif current_section in ["args", "raises"] and current_key:
89
+ if key_pattern.match(line) or (original_indentation == 0 and stripped_line):
90
+ finalize_previous = True
91
+ elif current_section in ["returns", "tags"] and current_desc_lines:
92
+ if original_indentation == 0 and stripped_line:
93
+ finalize_previous = True
94
+ # SIM102 applied: Combine nested if/elif
95
+ elif (
96
+ not stripped_line
97
+ and current_desc_lines
98
+ and current_section in ["args", "raises", "returns", "tags"]
99
+ and (current_section not in ["args", "raises"] or current_key)
100
+ ):
101
+ finalize_previous = True
102
+
103
+ if finalize_previous:
104
+ finalize_current_item()
105
+ current_key = None
106
+ current_desc_lines = []
107
+ if not is_new_section_header or new_section_type == "other":
108
+ current_section = None
109
+
110
+ if is_new_section_header and new_section_type != "other":
111
+ current_section = new_section_type
112
+ # If Tags header had content, start accumulating it
113
+ if new_section_type == "tags" and header_content:
114
+ current_desc_lines.append(header_content)
115
+ # Don't process the header line itself further
116
+ continue
117
+
118
+ if not stripped_line:
119
+ continue
120
+
121
+ if current_section == "args" or current_section == "raises":
122
+ match = key_pattern.match(line)
123
+ if match:
124
+ current_key = match.group(1)
125
+ current_desc_lines = [match.group(2).strip()] # Start new description
126
+ elif (
127
+ current_key and original_indentation > 0
128
+ ): # Check for indentation for continuation
129
+ current_desc_lines.append(stripped_line)
130
+
131
+ elif current_section == "returns":
132
+ if not current_desc_lines or original_indentation > 0:
133
+ current_desc_lines.append(stripped_line)
134
+
135
+ elif current_section == "tags":
136
+ if (
137
+ original_indentation > 0 or not current_desc_lines
138
+ ): # Indented or first line
139
+ current_desc_lines.append(stripped_line)
140
+
141
+ finalize_current_item()
142
+ return {
143
+ "summary": summary,
144
+ "args": args,
145
+ "returns": returns,
146
+ "raises": raises,
147
+ "tags": tags,
148
+ }
149
+
150
+
151
+ docstring_example = """
152
+ Starts a crawl job for a given URL using Firecrawl. Returns the job ID immediately.
153
+
154
+ Args:
155
+ url: The starting URL for the crawl.
156
+ It can be a very long url that spans multiple lines if needed.
157
+ params: Optional dictionary of parameters to customize the crawl.
158
+ See API docs for details.
159
+ idempotency_key: Optional unique key to prevent duplicate jobs.
160
+
161
+ Returns:
162
+ A dictionary containing the job initiation response on success,
163
+ or a string containing an error message on failure. This description
164
+ can also span multiple lines.
165
+
166
+ Raises:
167
+ ValueError: If the URL is invalid.
168
+ requests.exceptions.ConnectionError: If connection fails.
169
+
170
+ Tags:
171
+ crawl, async_job, start, api, long_tag_example , another
172
+ , final_tag
173
+ """
174
+
175
+ if __name__ == "__main__":
176
+ parsed = parse_docstring(docstring_example)
177
+ import json
178
+
179
+ print(json.dumps(parsed, indent=4))
@@ -7,62 +7,72 @@ from universal_mcp.applications import app_from_slug
7
7
  def discover_available_app_slugs():
8
8
  apps_dir = Path(__file__).resolve().parent.parent / "applications"
9
9
  app_slugs = []
10
-
10
+
11
11
  for item in apps_dir.iterdir():
12
- if not item.is_dir() or item.name.startswith('_'):
12
+ if not item.is_dir() or item.name.startswith("_"):
13
13
  continue
14
-
14
+
15
15
  if (item / "app.py").exists():
16
16
  slug = item.name.replace("_", "-")
17
17
  app_slugs.append(slug)
18
-
18
+
19
19
  return app_slugs
20
20
 
21
+
21
22
  def extract_app_tools(app_slugs):
22
23
  all_apps_tools = []
23
-
24
+
24
25
  for slug in app_slugs:
25
26
  try:
26
27
  print(f"Loading app: {slug}")
27
28
  app_class = app_from_slug(slug)
28
-
29
+
29
30
  app_instance = app_class(integration=None)
30
-
31
+
31
32
  tools = app_instance.list_tools()
32
-
33
+
33
34
  for tool in tools:
34
35
  tool_name = tool.__name__
35
- description = tool.__doc__.strip().split('\n')[0] if tool.__doc__ else "No description"
36
-
37
- all_apps_tools.append({
38
- "app_name": slug,
39
- "tool_name": tool_name,
40
- "description": description
41
- })
42
-
36
+ description = (
37
+ tool.__doc__.strip().split("\n")[0]
38
+ if tool.__doc__
39
+ else "No description"
40
+ )
41
+
42
+ all_apps_tools.append(
43
+ {
44
+ "app_name": slug,
45
+ "tool_name": tool_name,
46
+ "description": description,
47
+ }
48
+ )
49
+
43
50
  except Exception as e:
44
51
  print(f"Error loading app {slug}: {e}")
45
-
52
+
46
53
  return all_apps_tools
47
54
 
55
+
48
56
  def write_to_csv(app_tools, output_file="app_tools.csv"):
49
57
  fieldnames = ["app_name", "tool_name", "description"]
50
-
51
- with open(output_file, 'w', newline='') as csvfile:
58
+
59
+ with open(output_file, "w", newline="") as csvfile:
52
60
  writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
53
61
  writer.writeheader()
54
62
  writer.writerows(app_tools)
55
-
63
+
56
64
  print(f"CSV file created: {output_file}")
57
65
 
66
+
58
67
  def main():
59
68
  app_slugs = discover_available_app_slugs()
60
69
  print(f"Found {len(app_slugs)} app slugs: {', '.join(app_slugs)}")
61
-
70
+
62
71
  app_tools = extract_app_tools(app_slugs)
63
72
  print(f"Extracted {len(app_tools)} tools from all apps")
64
-
73
+
65
74
  write_to_csv(app_tools)
66
75
 
76
+
67
77
  if __name__ == "__main__":
68
- main()
78
+ main()
@@ -29,7 +29,7 @@ def create_file_if_not_exists(path: Path) -> None:
29
29
 
30
30
  def get_supported_apps() -> list[str]:
31
31
  """Get list of supported apps"""
32
- return ["claude", "cursor", "windsurf"]
32
+ return ["claude", "cursor", "cline", "continue", "goose", "windsurf", "zed"]
33
33
 
34
34
 
35
35
  def install_claude(api_key: str) -> None:
@@ -61,7 +61,7 @@ def install_claude(api_key: str) -> None:
61
61
  config["mcpServers"] = {}
62
62
  config["mcpServers"]["universal_mcp"] = {
63
63
  "command": get_uvx_path(),
64
- "args": ["universal_mcp@latest", "run"],
64
+ "args": ["universal_mcp[all]@latest", "run"],
65
65
  "env": {"AGENTR_API_KEY": api_key},
66
66
  }
67
67
  with open(config_path, "w") as f:
@@ -90,7 +90,7 @@ def install_cursor(api_key: str) -> None:
90
90
  config["mcpServers"] = {}
91
91
  config["mcpServers"]["universal_mcp"] = {
92
92
  "command": get_uvx_path(),
93
- "args": ["universal_mcp@latest", "run"],
93
+ "args": ["universal_mcp[all]@latest", "run"],
94
94
  "env": {"AGENTR_API_KEY": api_key},
95
95
  }
96
96
 
@@ -99,21 +99,212 @@ def install_cursor(api_key: str) -> None:
99
99
  print("[green]✓[/green] Cursor configuration installed successfully")
100
100
 
101
101
 
102
- def install_windsurf() -> None:
102
+ def install_cline(api_key: str) -> None:
103
+ """Install Cline"""
104
+ print("[bold blue]Installing Cline configuration...[/bold blue]")
105
+ # Set up Cline config path
106
+ config_path = Path.home() / ".config/cline/mcp.json"
107
+
108
+ # Create config directory if it doesn't exist
109
+ create_file_if_not_exists(config_path)
110
+
111
+ try:
112
+ config = json.loads(config_path.read_text())
113
+ except json.JSONDecodeError:
114
+ print(
115
+ "[yellow]Config file was empty or invalid, creating new configuration[/yellow]"
116
+ )
117
+ config = {}
118
+
119
+ if "mcpServers" not in config:
120
+ config["mcpServers"] = {}
121
+ config["mcpServers"]["universal_mcp"] = {
122
+ "command": get_uvx_path(),
123
+ "args": ["universal_mcp[all]@latest", "run"],
124
+ "env": {"AGENTR_API_KEY": api_key},
125
+ }
126
+
127
+ with open(config_path, "w") as f:
128
+ json.dump(config, f, indent=4)
129
+ print("[green]✓[/green] Cline configuration installed successfully")
130
+
131
+
132
+ def install_continue(api_key: str) -> None:
133
+ """Install Continue"""
134
+ print("[bold blue]Installing Continue configuration...[/bold blue]")
135
+
136
+ # Determine platform-specific config path
137
+ if sys.platform == "darwin": # macOS
138
+ config_path = Path.home() / "Library/Application Support/Continue/mcp.json"
139
+ elif sys.platform == "win32": # Windows
140
+ config_path = Path.home() / "AppData/Roaming/Continue/mcp.json"
141
+ else: # Linux and others
142
+ config_path = Path.home() / ".config/continue/mcp.json"
143
+
144
+ # Create config directory if it doesn't exist
145
+ create_file_if_not_exists(config_path)
146
+
147
+ try:
148
+ config = json.loads(config_path.read_text())
149
+ except json.JSONDecodeError:
150
+ print(
151
+ "[yellow]Config file was empty or invalid, creating new configuration[/yellow]"
152
+ )
153
+ config = {}
154
+
155
+ if "mcpServers" not in config:
156
+ config["mcpServers"] = {}
157
+ config["mcpServers"]["universal_mcp"] = {
158
+ "command": get_uvx_path(),
159
+ "args": ["universal_mcp[all]@latest", "run"],
160
+ "env": {"AGENTR_API_KEY": api_key},
161
+ }
162
+
163
+ with open(config_path, "w") as f:
164
+ json.dump(config, f, indent=4)
165
+ print("[green]✓[/green] Continue configuration installed successfully")
166
+
167
+
168
+ def install_goose(api_key: str) -> None:
169
+ """Install Goose"""
170
+ print("[bold blue]Installing Goose configuration...[/bold blue]")
171
+
172
+ # Determine platform-specific config path
173
+ if sys.platform == "darwin": # macOS
174
+ config_path = Path.home() / "Library/Application Support/Goose/mcp-config.json"
175
+ elif sys.platform == "win32": # Windows
176
+ config_path = Path.home() / "AppData/Roaming/Goose/mcp-config.json"
177
+ else: # Linux and others
178
+ config_path = Path.home() / ".config/goose/mcp-config.json"
179
+
180
+ # Create config directory if it doesn't exist
181
+ create_file_if_not_exists(config_path)
182
+
183
+ try:
184
+ config = json.loads(config_path.read_text())
185
+ except json.JSONDecodeError:
186
+ print(
187
+ "[yellow]Config file was empty or invalid, creating new configuration[/yellow]"
188
+ )
189
+ config = {}
190
+
191
+ if "mcpServers" not in config:
192
+ config["mcpServers"] = {}
193
+ config["mcpServers"]["universal_mcp"] = {
194
+ "command": get_uvx_path(),
195
+ "args": ["universal_mcp[all]@latest", "run"],
196
+ "env": {"AGENTR_API_KEY": api_key},
197
+ }
198
+
199
+ with open(config_path, "w") as f:
200
+ json.dump(config, f, indent=4)
201
+ print("[green]✓[/green] Goose configuration installed successfully")
202
+
203
+
204
+ def install_windsurf(api_key: str) -> None:
103
205
  """Install Windsurf"""
104
- print("[yellow]Windsurf installation not yet implemented[/yellow]")
105
- pass
206
+ print("[bold blue]Installing Windsurf configuration...[/bold blue]")
207
+
208
+ # Determine platform-specific config path
209
+ if sys.platform == "darwin": # macOS
210
+ config_path = Path.home() / "Library/Application Support/Windsurf/mcp.json"
211
+ elif sys.platform == "win32": # Windows
212
+ config_path = Path.home() / "AppData/Roaming/Windsurf/mcp.json"
213
+ else: # Linux and others
214
+ config_path = Path.home() / ".config/windsurf/mcp.json"
215
+
216
+ # Create config directory if it doesn't exist
217
+ create_file_if_not_exists(config_path)
218
+
219
+ try:
220
+ config = json.loads(config_path.read_text())
221
+ except json.JSONDecodeError:
222
+ print(
223
+ "[yellow]Config file was empty or invalid, creating new configuration[/yellow]"
224
+ )
225
+ config = {}
226
+
227
+ if "mcpServers" not in config:
228
+ config["mcpServers"] = {}
229
+ config["mcpServers"]["universal_mcp"] = {
230
+ "command": get_uvx_path(),
231
+ "args": ["universal_mcp[all]@latest", "run"],
232
+ "env": {"AGENTR_API_KEY": api_key},
233
+ }
234
+
235
+ with open(config_path, "w") as f:
236
+ json.dump(config, f, indent=4)
237
+ print("[green]✓[/green] Windsurf configuration installed successfully")
238
+
239
+
240
+ def install_zed(api_key: str) -> None:
241
+ """Install Zed"""
242
+ print("[bold blue]Installing Zed configuration...[/bold blue]")
243
+
244
+ # Set up Zed config path
245
+ config_dir = Path.home() / ".config/zed"
246
+ config_path = config_dir / "mcp_servers.json"
247
+
248
+ # Create config directory if it doesn't exist
249
+ create_file_if_not_exists(config_path)
250
+
251
+ try:
252
+ config = json.loads(config_path.read_text())
253
+ except json.JSONDecodeError:
254
+ print(
255
+ "[yellow]Config file was empty or invalid, creating new configuration[/yellow]"
256
+ )
257
+ config = {}
258
+
259
+ if not isinstance(config, list):
260
+ config = []
261
+
262
+ # Check if universal_mcp is already in the config
263
+ existing_config = False
264
+ for server in config:
265
+ if server.get("name") == "universal_mcp":
266
+ existing_config = True
267
+ server.update(
268
+ {
269
+ "command": get_uvx_path(),
270
+ "args": ["universal_mcp[all]@latest", "run"],
271
+ "env": {"AGENTR_API_KEY": api_key},
272
+ }
273
+ )
274
+ break
275
+
276
+ if not existing_config:
277
+ config.append(
278
+ {
279
+ "name": "universal_mcp",
280
+ "command": get_uvx_path(),
281
+ "args": ["universal_mcp[all]@latest", "run"],
282
+ "env": {"AGENTR_API_KEY": api_key},
283
+ }
284
+ )
285
+
286
+ with open(config_path, "w") as f:
287
+ json.dump(config, f, indent=4)
288
+ print("[green]✓[/green] Zed configuration installed successfully")
106
289
 
107
290
 
108
- def install_app(app_name: str) -> None:
291
+ def install_app(app_name: str, api_key: str) -> None:
109
292
  """Install an app"""
110
293
  print(f"[bold]Installing {app_name}...[/bold]")
111
294
  if app_name == "claude":
112
- install_claude()
295
+ install_claude(api_key)
113
296
  elif app_name == "cursor":
114
- install_cursor()
297
+ install_cursor(api_key)
298
+ elif app_name == "cline":
299
+ install_cline(api_key)
300
+ elif app_name == "continue":
301
+ install_continue(api_key)
302
+ elif app_name == "goose":
303
+ install_goose(api_key)
115
304
  elif app_name == "windsurf":
116
- install_windsurf()
305
+ install_windsurf(api_key)
306
+ elif app_name == "zed":
307
+ install_zed(api_key)
117
308
  else:
118
309
  print(f"[red]Error: App '{app_name}' not supported[/red]")
119
310
  raise ValueError(f"App '{app_name}' not supported")