universal-mcp 0.1.13rc3__py3-none-any.whl → 0.1.13rc14__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.
@@ -1,4 +1,7 @@
1
1
  import importlib
2
+ import os
3
+ import subprocess
4
+ import sys
2
5
 
3
6
  from loguru import logger
4
7
 
@@ -7,12 +10,20 @@ from universal_mcp.applications.application import (
7
10
  BaseApplication,
8
11
  GraphQLApplication,
9
12
  )
10
- import subprocess
13
+
14
+ UNIVERSAL_MCP_HOME = os.path.join(os.path.expanduser("~"), ".universal-mcp", "packages")
15
+
16
+ if not os.path.exists(UNIVERSAL_MCP_HOME):
17
+ os.makedirs(UNIVERSAL_MCP_HOME)
18
+
19
+ # set python path to include the universal-mcp home directory
20
+ sys.path.append(UNIVERSAL_MCP_HOME)
21
+
11
22
 
12
23
  # Name are in the format of "app-name", eg, google-calendar
13
- # Folder name is "app_name", eg, google_calendar
14
24
  # Class name is NameApp, eg, GoogleCalendarApp
15
25
 
26
+
16
27
  def _import_class(module_path: str, class_name: str):
17
28
  """
18
29
  Helper to import a class by name from a module.
@@ -27,23 +38,29 @@ def _import_class(module_path: str, class_name: str):
27
38
  return getattr(module, class_name)
28
39
  except AttributeError as e:
29
40
  logger.error(f"Class '{class_name}' not found in module '{module_path}'")
30
- raise ModuleNotFoundError(f"Class '{class_name}' not found in module '{module_path}'") from e
41
+ raise ModuleNotFoundError(
42
+ f"Class '{class_name}' not found in module '{module_path}'"
43
+ ) from e
44
+
31
45
 
32
46
  def _install_package(slug_clean: str):
33
47
  """
34
48
  Helper to install a package via pip from the universal-mcp GitHub repository.
35
49
  """
36
50
  repo_url = f"git+https://github.com/universal-mcp/{slug_clean}"
37
- cmd = ["uv", "pip", "install", repo_url]
51
+ cmd = ["uv", "pip", "install", repo_url, "--target", UNIVERSAL_MCP_HOME]
38
52
  logger.info(f"Installing package '{slug_clean}' with command: {' '.join(cmd)}")
39
53
  try:
40
54
  subprocess.check_call(cmd)
41
55
  except subprocess.CalledProcessError as e:
42
56
  logger.error(f"Installation failed for '{slug_clean}': {e}")
43
- raise ModuleNotFoundError(f"Installation failed for package '{slug_clean}'") from e
57
+ raise ModuleNotFoundError(
58
+ f"Installation failed for package '{slug_clean}'"
59
+ ) from e
44
60
  else:
45
61
  logger.info(f"Package '{slug_clean}' installed successfully")
46
62
 
63
+
47
64
  def app_from_slug(slug: str):
48
65
  """
49
66
  Dynamically resolve and return the application class for the given slug.
@@ -54,19 +71,26 @@ def app_from_slug(slug: str):
54
71
  package_prefix = f"universal_mcp_{slug_clean.replace('-', '_')}"
55
72
  module_path = f"{package_prefix}.app"
56
73
 
57
- logger.info(f"Resolving app for slug '{slug}' → module '{module_path}', class '{class_name}'")
74
+ logger.info(
75
+ f"Resolving app for slug '{slug}' → module '{module_path}', class '{class_name}'"
76
+ )
58
77
  try:
59
78
  return _import_class(module_path, class_name)
60
79
  except ModuleNotFoundError as orig_err:
61
- logger.warning(f"Module '{module_path}' not found locally: {orig_err}. Installing...")
80
+ logger.warning(
81
+ f"Module '{module_path}' not found locally: {orig_err}. Installing..."
82
+ )
62
83
  _install_package(slug_clean)
63
84
  # Retry import after installation
64
85
  try:
65
86
  return _import_class(module_path, class_name)
66
87
  except ModuleNotFoundError as retry_err:
67
- logger.error(f"Still cannot import '{module_path}' after installation: {retry_err}")
88
+ logger.error(
89
+ f"Still cannot import '{module_path}' after installation: {retry_err}"
90
+ )
68
91
  raise
69
92
 
93
+
70
94
  __all__ = [
71
95
  "app_from_slug",
72
96
  "BaseApplication",
universal_mcp/cli.py CHANGED
@@ -1,11 +1,9 @@
1
- import asyncio
2
- import os
1
+ import re
3
2
  from pathlib import Path
4
3
 
5
4
  import typer
6
5
  from rich import print as rprint
7
6
  from rich.panel import Panel
8
- import re
9
7
 
10
8
  from universal_mcp.utils.installation import (
11
9
  get_supported_apps,
@@ -25,6 +23,12 @@ def generate(
25
23
  "-o",
26
24
  help="Output file path - should match the API name (e.g., 'twitter.py' for Twitter API)",
27
25
  ),
26
+ class_name: str = typer.Option(
27
+ None,
28
+ "--class-name",
29
+ "-c",
30
+ help="Class name to use for the API client",
31
+ ),
28
32
  ):
29
33
  """Generate API client from OpenAPI schema with optional docstring generation.
30
34
 
@@ -40,25 +44,35 @@ def generate(
40
44
 
41
45
  try:
42
46
  # Run the async function in the event loop
43
- result = generate_api_from_schema(
44
- schema_path=schema_path,
45
- output_path=output_path,
46
- )
47
-
48
- if not output_path:
49
- # Print to stdout if no output path
50
- print(result["code"])
51
- else:
52
- typer.echo("API client successfully generated and installed.")
53
- if "app_file" in result:
54
- typer.echo(f"Application file: {result['app_file']}")
55
- if "readme_file" in result and result["readme_file"]:
56
- typer.echo(f"Documentation: {result['readme_file']}")
47
+ app_file = generate_api_from_schema(
48
+ schema_path=schema_path,
49
+ output_path=output_path,
50
+ class_name=class_name,
51
+ )
52
+ typer.echo("API client successfully generated and installed.")
53
+ typer.echo(f"Application file: {app_file}")
57
54
  except Exception as e:
58
55
  typer.echo(f"Error generating API client: {e}", err=True)
59
56
  raise typer.Exit(1) from e
60
57
 
61
58
 
59
+ @app.command()
60
+ def readme(
61
+ file_path: Path = typer.Argument(..., help="Path to the Python file to process"),
62
+ class_name: str = typer.Option(
63
+ None,
64
+ "--class-name",
65
+ "-c",
66
+ help="Class name to use for the API client",
67
+ ),
68
+ ):
69
+ """Generate a README.md file for the API client."""
70
+ from universal_mcp.utils.readme import generate_readme
71
+
72
+ readme_file = generate_readme(file_path, class_name)
73
+ typer.echo(f"README.md file generated at: {readme_file}")
74
+
75
+
62
76
  @app.command()
63
77
  def docgen(
64
78
  file_path: Path = typer.Argument(..., help="Path to the Python file to process"),
@@ -68,7 +82,6 @@ def docgen(
68
82
  "-m",
69
83
  help="Model to use for generating docstrings",
70
84
  ),
71
-
72
85
  ):
73
86
  """Generate docstrings for Python files using LLMs.
74
87
 
@@ -154,6 +167,7 @@ def install(app_name: str = typer.Argument(..., help="Name of app to install")):
154
167
  typer.echo(f"Error installing app: {e}", err=True)
155
168
  raise typer.Exit(1) from e
156
169
 
170
+
157
171
  @app.command()
158
172
  def init(
159
173
  output_dir: Path | None = typer.Option(
@@ -162,13 +176,13 @@ def init(
162
176
  "-o",
163
177
  help="Output directory for the project (must exist)",
164
178
  ),
165
- app_name: str|None = typer.Option(
179
+ app_name: str | None = typer.Option(
166
180
  None,
167
181
  "--app-name",
168
182
  "-a",
169
183
  help="App name (letters, numbers, hyphens, underscores only)",
170
184
  ),
171
- integration_type: str|None = typer.Option(
185
+ integration_type: str | None = typer.Option(
172
186
  None,
173
187
  "--integration-type",
174
188
  "-i",
@@ -195,7 +209,7 @@ def init(
195
209
  app_name = typer.prompt(
196
210
  "Enter the app name",
197
211
  default="app_name",
198
- prompt_suffix=" (e.g., reddit, youtube): "
212
+ prompt_suffix=" (e.g., reddit, youtube): ",
199
213
  ).strip()
200
214
  validate_pattern(app_name, "app name")
201
215
 
@@ -203,10 +217,10 @@ def init(
203
217
  path_str = typer.prompt(
204
218
  "Enter the output directory for the project",
205
219
  default=str(Path.cwd()),
206
- prompt_suffix=": "
220
+ prompt_suffix=": ",
207
221
  ).strip()
208
222
  output_dir = Path(path_str)
209
-
223
+
210
224
  if not output_dir.exists():
211
225
  try:
212
226
  output_dir.mkdir(parents=True, exist_ok=True)
@@ -219,7 +233,7 @@ def init(
219
233
  f"❌ Failed to create output directory '{output_dir}': {e}",
220
234
  fg=typer.colors.RED,
221
235
  )
222
- raise typer.Exit(code=1)
236
+ raise typer.Exit(code=1) from e
223
237
  elif not output_dir.is_dir():
224
238
  typer.secho(
225
239
  f"❌ Output path '{output_dir}' exists but is not a directory.",
@@ -232,7 +246,7 @@ def init(
232
246
  integration_type = typer.prompt(
233
247
  "Choose the integration type",
234
248
  default="agentr",
235
- prompt_suffix=" (api_key, oauth, agentr, none): "
249
+ prompt_suffix=" (api_key, oauth, agentr, none): ",
236
250
  ).lower()
237
251
  if integration_type not in ("api_key", "oauth", "agentr", "none"):
238
252
  typer.secho(
@@ -240,7 +254,6 @@ def init(
240
254
  fg=typer.colors.RED,
241
255
  )
242
256
  raise typer.Exit(code=1)
243
-
244
257
 
245
258
  typer.secho("🚀 Generating project using cookiecutter...", fg=typer.colors.BLUE)
246
259
  try:
@@ -255,10 +268,11 @@ def init(
255
268
  )
256
269
  except Exception as exc:
257
270
  typer.secho(f"❌ Project generation failed: {exc}", fg=typer.colors.RED)
258
- raise typer.Exit(code=1)
271
+ raise typer.Exit(code=1) from exc
259
272
 
260
273
  project_dir = output_dir / f"universal-mcp-{app_name}"
261
274
  typer.secho(f"✅ Project created at {project_dir}", fg=typer.colors.GREEN)
262
275
 
276
+
263
277
  if __name__ == "__main__":
264
278
  app()
@@ -1,9 +1,9 @@
1
1
  from universal_mcp.config import IntegrationConfig
2
2
  from universal_mcp.integrations.integration import (
3
+ AgentRIntegration,
3
4
  ApiKeyIntegration,
4
5
  Integration,
5
6
  OAuthIntegration,
6
- AgentRIntegration,
7
7
  )
8
8
  from universal_mcp.stores.store import BaseStore
9
9
 
@@ -1,8 +1,6 @@
1
- import os
2
1
  from abc import ABC, abstractmethod
3
2
  from collections.abc import Callable
4
3
  from typing import Any
5
- from urllib.parse import urlparse
6
4
 
7
5
  import httpx
8
6
  from loguru import logger
@@ -11,11 +11,13 @@ class StoreError(Exception):
11
11
 
12
12
  pass
13
13
 
14
+
14
15
  class KeyNotFoundError(StoreError):
15
16
  """Exception raised when a key is not found in the store."""
16
17
 
17
18
  pass
18
19
 
20
+
19
21
  class BaseStore(ABC):
20
22
  """
21
23
  Abstract base class defining the interface for credential stores.
@@ -260,9 +260,7 @@ class ToolManager:
260
260
  try:
261
261
  available_tool_functions = app.list_tools()
262
262
  except TypeError as e:
263
- logger.error(
264
- f"Error calling list_tools for app '{app.name}'. Error: {e}"
265
- )
263
+ logger.error(f"Error calling list_tools for app '{app.name}'. Error: {e}")
266
264
  return
267
265
  except Exception as e:
268
266
  logger.error(f"Failed to get tool list from app '{app.name}': {e}")
@@ -1,9 +1,13 @@
1
- from loguru import logger
2
1
  import os
2
+
3
3
  import httpx
4
+ from loguru import logger
5
+
4
6
  from universal_mcp.config import AppConfig
7
+ from universal_mcp.exceptions import NotAuthorizedError
5
8
  from universal_mcp.utils.singleton import Singleton
6
9
 
10
+
7
11
  class AgentrClient(metaclass=Singleton):
8
12
  """Helper class for AgentR API operations.
9
13
 
@@ -22,7 +26,9 @@ class AgentrClient(metaclass=Singleton):
22
26
  "API key for AgentR is missing. Please visit https://agentr.dev to create an API key, then set it as AGENTR_API_KEY environment variable."
23
27
  )
24
28
  raise ValueError("AgentR API key required - get one at https://agentr.dev")
25
- self.base_url = (base_url or os.getenv("AGENTR_BASE_URL", "https://api.agentr.dev")).rstrip("/")
29
+ self.base_url = (
30
+ base_url or os.getenv("AGENTR_BASE_URL", "https://api.agentr.dev")
31
+ ).rstrip("/")
26
32
 
27
33
  def get_credentials(self, integration_name: str) -> dict:
28
34
  """Get credentials for an integration from the AgentR API.
@@ -87,4 +93,3 @@ class AgentrClient(metaclass=Singleton):
87
93
  response.raise_for_status()
88
94
  data = response.json()
89
95
  return [AppConfig.model_validate(app) for app in data]
90
-
@@ -1,10 +1,10 @@
1
+ import importlib.util
1
2
  import inspect
2
3
  import os
4
+ import shutil
3
5
  from pathlib import Path
6
+
4
7
  from loguru import logger
5
- import shutil
6
- import importlib.util
7
- from jinja2 import Environment, FileSystemLoader, TemplateError, select_autoescape
8
8
 
9
9
  from universal_mcp.utils.openapi import generate_api_client, load_schema
10
10
 
@@ -26,6 +26,7 @@ def validate_and_load_schema(schema_path: Path) -> dict:
26
26
  echo(f"Error loading schema: {e}", err=True)
27
27
  raise
28
28
 
29
+
29
30
  def get_class_info(module: any) -> tuple[str | None, any]:
30
31
  """Find the main class in the generated module."""
31
32
  for name, obj in inspect.getmembers(module):
@@ -33,71 +34,6 @@ def get_class_info(module: any) -> tuple[str | None, any]:
33
34
  return name, obj
34
35
  return None, None
35
36
 
36
- def generate_readme(
37
- app_dir: Path, folder_name: str, tools: list
38
- ) -> Path:
39
- """Generate README.md with API documentation.
40
-
41
- Args:
42
- app_dir: Directory where the README will be generated
43
- folder_name: Name of the application folder
44
- tools: List of Function objects from the OpenAPI schema
45
-
46
- Returns:
47
- Path to the generated README file
48
-
49
- Raises:
50
- FileNotFoundError: If the template directory doesn't exist
51
- TemplateError: If there's an error rendering the template
52
- IOError: If there's an error writing the README file
53
- """
54
- app = folder_name.replace("_", " ").title()
55
- logger.info(f"Generating README for {app} in {app_dir}")
56
-
57
- # Format tools into (name, description) tuples
58
- formatted_tools = []
59
- for tool in tools:
60
- name = tool.__name__
61
- description = tool.__doc__.strip().split("\n")[0]
62
- formatted_tools.append((name, description))
63
-
64
- # Set up Jinja2 environment
65
- template_dir = Path(__file__).parent.parent / "templates"
66
- if not template_dir.exists():
67
- logger.error(f"Template directory not found: {template_dir}")
68
- raise FileNotFoundError(f"Template directory not found: {template_dir}")
69
-
70
- try:
71
- env = Environment(
72
- loader=FileSystemLoader(template_dir),
73
- autoescape=select_autoescape()
74
- )
75
- template = env.get_template("README.md.j2")
76
- except Exception as e:
77
- logger.error(f"Error loading template: {e}")
78
- raise TemplateError(f"Error loading template: {e}")
79
-
80
- # Render the template
81
- try:
82
- readme_content = template.render(
83
- name=app,
84
- tools=formatted_tools
85
- )
86
- except Exception as e:
87
- logger.error(f"Error rendering template: {e}")
88
- raise TemplateError(f"Error rendering template: {e}")
89
-
90
- # Write the README file
91
- readme_file = app_dir / "README.md"
92
- try:
93
- with open(readme_file, "w") as f:
94
- f.write(readme_content)
95
- logger.info(f"Documentation generated at: {readme_file}")
96
- except Exception as e:
97
- logger.error(f"Error writing README file: {e}")
98
- raise IOError(f"Error writing README file: {e}")
99
-
100
- return readme_file
101
37
 
102
38
  def test_correct_output(gen_file: Path):
103
39
  # Check file is non-empty
@@ -122,7 +58,7 @@ def test_correct_output(gen_file: Path):
122
58
  def generate_api_from_schema(
123
59
  schema_path: Path,
124
60
  output_path: Path | None = None,
125
- add_docstrings: bool = True,
61
+ class_name: str | None = None,
126
62
  ) -> tuple[Path, Path]:
127
63
  """
128
64
  Generate API client from OpenAPI schema and write to app.py with a README.
@@ -137,7 +73,6 @@ def generate_api_from_schema(
137
73
  """
138
74
  # Local imports for logging and file operations
139
75
 
140
-
141
76
  logger.info("Starting API generation for schema: %s", schema_path)
142
77
 
143
78
  # 1. Parse and validate schema
@@ -150,7 +85,7 @@ def generate_api_from_schema(
150
85
 
151
86
  # 2. Generate client code
152
87
  try:
153
- code = generate_api_client(schema)
88
+ code = generate_api_client(schema, class_name)
154
89
  logger.info("API client code generated.")
155
90
  except Exception as e:
156
91
  logger.error("Code generation failed: %s", e)
@@ -174,10 +109,15 @@ def generate_api_from_schema(
174
109
  f.write(code)
175
110
 
176
111
  if not test_correct_output(gen_file):
177
- logger.error("Generated code validation failed for '%s'. Aborting generation.", gen_file)
112
+ logger.error(
113
+ "Generated code validation failed for '%s'. Aborting generation.", gen_file
114
+ )
178
115
  logger.info("Next steps:")
179
116
  logger.info(" 1) Review your OpenAPI schema for potential mismatches.")
180
- logger.info(" 2) Inspect '%s' for syntax or logic errors in the generated code.", gen_file)
117
+ logger.info(
118
+ " 2) Inspect '%s' for syntax or logic errors in the generated code.",
119
+ gen_file,
120
+ )
181
121
  logger.info(" 3) Correct the issues and re-run the command.")
182
122
  return {"error": "Validation failed. See logs above for detailed instructions."}
183
123
 
@@ -190,33 +130,6 @@ def generate_api_from_schema(
190
130
  shutil.copy(gen_file, app_file)
191
131
  logger.info("App file written to: %s", app_file)
192
132
 
193
- # 6. Collect tools and generate README
194
- import importlib.util
195
- import sys
196
-
197
- # Load the generated module as "temp_module"
198
- spec = importlib.util.spec_from_file_location("temp_module", str(app_file))
199
- module = importlib.util.module_from_spec(spec)
200
- sys.modules["temp_module"] = module
201
- spec.loader.exec_module(module)
202
-
203
- # Retrieve the generated API class
204
- class_name, cls = get_class_info(module)
205
-
206
- # Instantiate client and collect its tools
207
- tools = []
208
- if cls:
209
- try:
210
- client = cls()
211
- tools = client.list_tools()
212
- except Exception as e:
213
- logger.warning("Failed to instantiate '%s' or list tools: %s", class_name, e)
214
- else:
215
- logger.warning("No generated class found in module 'temp_module'")
216
- readme_file = generate_readme(target_dir, output_path.stem, tools)
217
- logger.info("README generated at: %s", readme_file)
218
-
219
-
220
133
  # Cleanup intermediate file
221
134
  try:
222
135
  os.remove(gen_file)
@@ -224,4 +137,4 @@ def generate_api_from_schema(
224
137
  except Exception as e:
225
138
  logger.warning("Could not remove intermediate file %s: %s", gen_file, e)
226
139
 
227
- return app_file, readme_file
140
+ return app_file
@@ -176,7 +176,7 @@ def extract_json_from_text(text):
176
176
 
177
177
 
178
178
  def generate_docstring(
179
- function_code: str, model: str = "perplexity/sonar-pro"
179
+ function_code: str, model: str = "perplexity/sonar"
180
180
  ) -> DocstringOutput:
181
181
  """
182
182
  Generate a docstring for a Python function using litellm with structured output.
@@ -509,7 +509,7 @@ def insert_docstring_into_function(function_code: str, docstring: str) -> str:
509
509
  return function_code
510
510
 
511
511
 
512
- def process_file(file_path: str, model: str = "perplexity/sonar-pro") -> int:
512
+ def process_file(file_path: str, model: str = "perplexity/sonar") -> int:
513
513
  """
514
514
  Process a Python file and add docstrings to all functions in it.
515
515