universal-mcp 0.1.13rc14__py3-none-any.whl → 0.1.15rc5__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.
- universal_mcp/cli.py +34 -37
- universal_mcp/config.py +95 -22
- universal_mcp/logger.py +59 -8
- universal_mcp/servers/__init__.py +2 -2
- universal_mcp/templates/README.md.j2 +0 -76
- universal_mcp/tools/__init__.py +2 -1
- universal_mcp/tools/adapters.py +14 -0
- universal_mcp/tools/func_metadata.py +1 -1
- universal_mcp/tools/manager.py +180 -0
- universal_mcp/tools/tools.py +2 -246
- universal_mcp/utils/installation.py +8 -8
- universal_mcp/utils/openapi.py +3 -54
- {universal_mcp-0.1.13rc14.dist-info → universal_mcp-0.1.15rc5.dist-info}/METADATA +2 -1
- {universal_mcp-0.1.13rc14.dist-info → universal_mcp-0.1.15rc5.dist-info}/RECORD +16 -16
- universal_mcp/utils/dump_app_tools.py +0 -78
- {universal_mcp-0.1.13rc14.dist-info → universal_mcp-0.1.15rc5.dist-info}/WHEEL +0 -0
- {universal_mcp-0.1.13rc14.dist-info → universal_mcp-0.1.15rc5.dist-info}/entry_points.txt +0 -0
universal_mcp/cli.py
CHANGED
@@ -2,7 +2,7 @@ import re
|
|
2
2
|
from pathlib import Path
|
3
3
|
|
4
4
|
import typer
|
5
|
-
from rich import
|
5
|
+
from rich.console import Console
|
6
6
|
from rich.panel import Panel
|
7
7
|
|
8
8
|
from universal_mcp.utils.installation import (
|
@@ -11,6 +11,9 @@ from universal_mcp.utils.installation import (
|
|
11
11
|
install_cursor,
|
12
12
|
)
|
13
13
|
|
14
|
+
# Setup rich console and logging
|
15
|
+
console = Console()
|
16
|
+
|
14
17
|
app = typer.Typer()
|
15
18
|
|
16
19
|
|
@@ -39,7 +42,7 @@ def generate(
|
|
39
42
|
from universal_mcp.utils.api_generator import generate_api_from_schema
|
40
43
|
|
41
44
|
if not schema_path.exists():
|
42
|
-
|
45
|
+
console.print(f"[red]Error: Schema file {schema_path} does not exist[/red]")
|
43
46
|
raise typer.Exit(1)
|
44
47
|
|
45
48
|
try:
|
@@ -49,10 +52,10 @@ def generate(
|
|
49
52
|
output_path=output_path,
|
50
53
|
class_name=class_name,
|
51
54
|
)
|
52
|
-
|
53
|
-
|
55
|
+
console.print("[green]API client successfully generated and installed.[/green]")
|
56
|
+
console.print(f"[blue]Application file: {app_file}[/blue]")
|
54
57
|
except Exception as e:
|
55
|
-
|
58
|
+
console.print(f"[red]Error generating API client: {e}[/red]")
|
56
59
|
raise typer.Exit(1) from e
|
57
60
|
|
58
61
|
|
@@ -70,7 +73,7 @@ def readme(
|
|
70
73
|
from universal_mcp.utils.readme import generate_readme
|
71
74
|
|
72
75
|
readme_file = generate_readme(file_path, class_name)
|
73
|
-
|
76
|
+
console.print(f"[green]README.md file generated at: {readme_file}[/green]")
|
74
77
|
|
75
78
|
|
76
79
|
@app.command()
|
@@ -91,14 +94,14 @@ def docgen(
|
|
91
94
|
from universal_mcp.utils.docgen import process_file
|
92
95
|
|
93
96
|
if not file_path.exists():
|
94
|
-
|
97
|
+
console.print(f"[red]Error: File not found: {file_path}[/red]")
|
95
98
|
raise typer.Exit(1)
|
96
99
|
|
97
100
|
try:
|
98
101
|
processed = process_file(str(file_path), model)
|
99
|
-
|
102
|
+
console.print(f"[green]Successfully processed {processed} functions[/green]")
|
100
103
|
except Exception as e:
|
101
|
-
|
104
|
+
console.print(f"[red]Error: {e}[/red]")
|
102
105
|
raise typer.Exit(1) from e
|
103
106
|
|
104
107
|
|
@@ -130,15 +133,14 @@ def install(app_name: str = typer.Argument(..., help="Name of app to install")):
|
|
130
133
|
supported_apps = get_supported_apps()
|
131
134
|
|
132
135
|
if app_name not in supported_apps:
|
133
|
-
|
136
|
+
console.print("[yellow]Available apps:[/yellow]")
|
134
137
|
for app in supported_apps:
|
135
|
-
|
136
|
-
|
138
|
+
console.print(f" - {app}")
|
139
|
+
console.print(f"\n[red]App '{app_name}' not supported[/red]")
|
137
140
|
raise typer.Exit(1)
|
138
141
|
|
139
142
|
# Print instructions before asking for API key
|
140
|
-
|
141
|
-
rprint(
|
143
|
+
console.print(
|
142
144
|
Panel(
|
143
145
|
"API key is required. Visit [link]https://agentr.dev[/link] to create an API key.",
|
144
146
|
title="Instruction",
|
@@ -156,15 +158,15 @@ def install(app_name: str = typer.Argument(..., help="Name of app to install")):
|
|
156
158
|
)
|
157
159
|
try:
|
158
160
|
if app_name == "claude":
|
159
|
-
|
161
|
+
console.print(f"[blue]Installing mcp server for: {app_name}[/blue]")
|
160
162
|
install_claude(api_key)
|
161
|
-
|
163
|
+
console.print("[green]App installed successfully[/green]")
|
162
164
|
elif app_name == "cursor":
|
163
|
-
|
165
|
+
console.print(f"[blue]Installing mcp server for: {app_name}[/blue]")
|
164
166
|
install_cursor(api_key)
|
165
|
-
|
167
|
+
console.print("[green]App installed successfully[/green]")
|
166
168
|
except Exception as e:
|
167
|
-
|
169
|
+
console.print(f"[red]Error installing app: {e}[/red]")
|
168
170
|
raise typer.Exit(1) from e
|
169
171
|
|
170
172
|
|
@@ -198,9 +200,8 @@ def init(
|
|
198
200
|
|
199
201
|
def validate_pattern(value: str, field_name: str) -> None:
|
200
202
|
if not re.match(NAME_PATTERN, value):
|
201
|
-
|
202
|
-
f"❌ Invalid {field_name}; only letters, numbers, hyphens, and underscores allowed."
|
203
|
-
fg=typer.colors.RED,
|
203
|
+
console.print(
|
204
|
+
f"[red]❌ Invalid {field_name}; only letters, numbers, hyphens, and underscores allowed.[/red]"
|
204
205
|
)
|
205
206
|
raise typer.Exit(code=1)
|
206
207
|
|
@@ -224,20 +225,17 @@ def init(
|
|
224
225
|
if not output_dir.exists():
|
225
226
|
try:
|
226
227
|
output_dir.mkdir(parents=True, exist_ok=True)
|
227
|
-
|
228
|
-
f"✅ Created output directory at '{output_dir}'"
|
229
|
-
fg=typer.colors.GREEN,
|
228
|
+
console.print(
|
229
|
+
f"[green]✅ Created output directory at '{output_dir}'[/green]"
|
230
230
|
)
|
231
231
|
except Exception as e:
|
232
|
-
|
233
|
-
f"❌ Failed to create output directory '{output_dir}': {e}"
|
234
|
-
fg=typer.colors.RED,
|
232
|
+
console.print(
|
233
|
+
f"[red]❌ Failed to create output directory '{output_dir}': {e}[/red]"
|
235
234
|
)
|
236
235
|
raise typer.Exit(code=1) from e
|
237
236
|
elif not output_dir.is_dir():
|
238
|
-
|
239
|
-
f"❌ Output path '{output_dir}' exists but is not a directory."
|
240
|
-
fg=typer.colors.RED,
|
237
|
+
console.print(
|
238
|
+
f"[red]❌ Output path '{output_dir}' exists but is not a directory.[/red]"
|
241
239
|
)
|
242
240
|
raise typer.Exit(code=1)
|
243
241
|
|
@@ -249,13 +247,12 @@ def init(
|
|
249
247
|
prompt_suffix=" (api_key, oauth, agentr, none): ",
|
250
248
|
).lower()
|
251
249
|
if integration_type not in ("api_key", "oauth", "agentr", "none"):
|
252
|
-
|
253
|
-
"❌ Integration type must be one of: api_key, oauth, agentr, none"
|
254
|
-
fg=typer.colors.RED,
|
250
|
+
console.print(
|
251
|
+
"[red]❌ Integration type must be one of: api_key, oauth, agentr, none[/red]"
|
255
252
|
)
|
256
253
|
raise typer.Exit(code=1)
|
257
254
|
|
258
|
-
|
255
|
+
console.print("[blue]🚀 Generating project using cookiecutter...[/blue]")
|
259
256
|
try:
|
260
257
|
cookiecutter(
|
261
258
|
"https://github.com/AgentrDev/universal-mcp-app-template.git",
|
@@ -267,11 +264,11 @@ def init(
|
|
267
264
|
},
|
268
265
|
)
|
269
266
|
except Exception as exc:
|
270
|
-
|
267
|
+
console.print(f"❌ Project generation failed: {exc}", fg=typer.colors.RED)
|
271
268
|
raise typer.Exit(code=1) from exc
|
272
269
|
|
273
270
|
project_dir = output_dir / f"universal-mcp-{app_name}"
|
274
|
-
|
271
|
+
console.print(f"✅ Project created at {project_dir}", fg=typer.colors.GREEN)
|
275
272
|
|
276
273
|
|
277
274
|
if __name__ == "__main__":
|
universal_mcp/config.py
CHANGED
@@ -1,32 +1,105 @@
|
|
1
|
-
from
|
1
|
+
from pathlib import Path
|
2
|
+
from typing import Any, Literal
|
2
3
|
|
3
|
-
from pydantic import BaseModel
|
4
|
+
from pydantic import BaseModel, Field, SecretStr, field_validator
|
5
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
4
6
|
|
5
7
|
|
6
8
|
class StoreConfig(BaseModel):
|
7
|
-
|
8
|
-
|
9
|
+
"""Configuration for credential storage."""
|
10
|
+
|
11
|
+
name: str = Field(default="universal_mcp", description="Name of the store")
|
12
|
+
type: Literal["memory", "environment", "keyring", "agentr"] = Field(
|
13
|
+
default="memory", description="Type of credential storage to use"
|
14
|
+
)
|
15
|
+
path: Path | None = Field(
|
16
|
+
default=None, description="Path to store credentials (if applicable)"
|
17
|
+
)
|
9
18
|
|
10
19
|
|
11
20
|
class IntegrationConfig(BaseModel):
|
12
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
21
|
+
"""Configuration for API integrations."""
|
22
|
+
|
23
|
+
name: str = Field(..., description="Name of the integration")
|
24
|
+
type: Literal["api_key", "oauth", "agentr", "oauth2"] = Field(
|
25
|
+
default="api_key", description="Type of authentication to use"
|
26
|
+
)
|
27
|
+
credentials: dict[str, Any] | None = Field(
|
28
|
+
default=None, description="Integration-specific credentials"
|
29
|
+
)
|
30
|
+
store: StoreConfig | None = Field(
|
31
|
+
default=None, description="Store configuration for credentials"
|
32
|
+
)
|
16
33
|
|
17
34
|
|
18
35
|
class AppConfig(BaseModel):
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
36
|
+
"""Configuration for individual applications."""
|
37
|
+
|
38
|
+
name: str = Field(..., description="Name of the application")
|
39
|
+
integration: IntegrationConfig | None = Field(
|
40
|
+
default=None, description="Integration configuration"
|
41
|
+
)
|
42
|
+
actions: list[str] | None = Field(
|
43
|
+
default=None, description="List of available actions"
|
44
|
+
)
|
45
|
+
|
46
|
+
|
47
|
+
class ServerConfig(BaseSettings):
|
48
|
+
"""Main server configuration."""
|
49
|
+
|
50
|
+
model_config = SettingsConfigDict(
|
51
|
+
env_prefix="MCP_",
|
52
|
+
env_file=".env",
|
53
|
+
env_file_encoding="utf-8",
|
54
|
+
case_sensitive=True,
|
55
|
+
extra="allow",
|
56
|
+
)
|
57
|
+
|
58
|
+
name: str = Field(default="Universal MCP", description="Name of the MCP server")
|
59
|
+
description: str = Field(
|
60
|
+
default="Universal MCP", description="Description of the MCP server"
|
61
|
+
)
|
62
|
+
api_key: SecretStr | None = Field(
|
63
|
+
default=None, description="API key for authentication"
|
64
|
+
)
|
65
|
+
type: Literal["local", "agentr"] = Field(
|
66
|
+
default="agentr", description="Type of server deployment"
|
67
|
+
)
|
68
|
+
transport: Literal["stdio", "sse", "http"] = Field(
|
69
|
+
default="stdio", description="Transport protocol to use"
|
70
|
+
)
|
71
|
+
port: int = Field(
|
72
|
+
default=8005, description="Port to run the server on (if applicable)"
|
73
|
+
)
|
74
|
+
host: str = Field(
|
75
|
+
default="localhost", description="Host to bind the server to (if applicable)"
|
76
|
+
)
|
77
|
+
apps: list[AppConfig] | None = Field(
|
78
|
+
default=None, description="List of configured applications"
|
79
|
+
)
|
80
|
+
store: StoreConfig | None = Field(
|
81
|
+
default=None, description="Default store configuration"
|
82
|
+
)
|
83
|
+
debug: bool = Field(default=False, description="Enable debug mode")
|
84
|
+
log_level: str = Field(default="INFO", description="Logging level")
|
85
|
+
max_connections: int = Field(
|
86
|
+
default=100, description="Maximum number of concurrent connections"
|
87
|
+
)
|
88
|
+
request_timeout: int = Field(
|
89
|
+
default=60, description="Default request timeout in seconds"
|
90
|
+
)
|
91
|
+
|
92
|
+
@field_validator("log_level", mode="before")
|
93
|
+
def validate_log_level(cls, v: str) -> str:
|
94
|
+
valid_levels = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
95
|
+
if v.upper() not in valid_levels:
|
96
|
+
raise ValueError(
|
97
|
+
f"Invalid log level. Must be one of: {', '.join(valid_levels)}"
|
98
|
+
)
|
99
|
+
return v.upper()
|
100
|
+
|
101
|
+
@field_validator("port", mode="before")
|
102
|
+
def validate_port(cls, v: int) -> int:
|
103
|
+
if not 1 <= v <= 65535:
|
104
|
+
raise ValueError("Port must be between 1 and 65535")
|
105
|
+
return v
|
universal_mcp/logger.py
CHANGED
@@ -1,17 +1,68 @@
|
|
1
1
|
import sys
|
2
|
+
from datetime import datetime
|
3
|
+
from pathlib import Path
|
2
4
|
|
3
5
|
from loguru import logger
|
4
6
|
|
5
7
|
|
6
|
-
def setup_logger(
|
8
|
+
def setup_logger(
|
9
|
+
log_file: Path | None = None,
|
10
|
+
rotation: str = "10 MB",
|
11
|
+
retention: str = "1 week",
|
12
|
+
compression: str = "zip",
|
13
|
+
level: str = "INFO",
|
14
|
+
format: str = "<green>{time:YYYY-MM-DD HH:mm:ss.SSS}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
|
15
|
+
) -> None:
|
16
|
+
"""Setup the logger with both stderr and optional file logging with rotation.
|
17
|
+
|
18
|
+
Args:
|
19
|
+
log_file: Optional path to log file. If None, only stderr logging is enabled.
|
20
|
+
rotation: When to rotate the log file. Can be size (e.g., "10 MB") or time (e.g., "1 day").
|
21
|
+
retention: How long to keep rotated log files (e.g., "1 week").
|
22
|
+
compression: Compression format for rotated logs ("zip", "gz", or None).
|
23
|
+
level: Minimum logging level.
|
24
|
+
format: Log message format string.
|
25
|
+
"""
|
26
|
+
# Remove default handler
|
7
27
|
logger.remove()
|
8
|
-
|
9
|
-
#
|
10
|
-
# sink=sys.stdout,
|
11
|
-
# level="INFO",
|
12
|
-
# )
|
13
|
-
# STDERR
|
28
|
+
|
29
|
+
# Add stderr handler
|
14
30
|
logger.add(
|
15
31
|
sink=sys.stderr,
|
16
|
-
level=
|
32
|
+
level=level,
|
33
|
+
format=format,
|
34
|
+
enqueue=True,
|
35
|
+
backtrace=True,
|
36
|
+
diagnose=True,
|
17
37
|
)
|
38
|
+
|
39
|
+
# Add file handler if log_file is specified
|
40
|
+
if log_file:
|
41
|
+
# Ensure log directory exists
|
42
|
+
log_file.parent.mkdir(parents=True, exist_ok=True)
|
43
|
+
|
44
|
+
logger.add(
|
45
|
+
sink=str(log_file),
|
46
|
+
rotation=rotation,
|
47
|
+
retention=retention,
|
48
|
+
compression=compression,
|
49
|
+
level=level,
|
50
|
+
format=format,
|
51
|
+
enqueue=True,
|
52
|
+
backtrace=True,
|
53
|
+
diagnose=True,
|
54
|
+
)
|
55
|
+
|
56
|
+
|
57
|
+
def get_log_file_path(app_name: str) -> Path:
|
58
|
+
"""Get a standardized log file path for an application.
|
59
|
+
|
60
|
+
Args:
|
61
|
+
app_name: Name of the application.
|
62
|
+
|
63
|
+
Returns:
|
64
|
+
Path to the log file in the format: logs/{app_name}/{app_name}_{date}.log
|
65
|
+
"""
|
66
|
+
date_str = datetime.now().strftime("%Y%m%d")
|
67
|
+
log_dir = Path("logs") / app_name
|
68
|
+
return log_dir / f"{app_name}_{date_str}.log"
|
@@ -1,5 +1,5 @@
|
|
1
1
|
from universal_mcp.config import ServerConfig
|
2
|
-
from universal_mcp.servers.server import AgentRServer, LocalServer
|
2
|
+
from universal_mcp.servers.server import AgentRServer, LocalServer, SingleMCPServer
|
3
3
|
|
4
4
|
|
5
5
|
def server_from_config(config: ServerConfig):
|
@@ -12,4 +12,4 @@ def server_from_config(config: ServerConfig):
|
|
12
12
|
raise ValueError(f"Unsupported server type: {config.type}")
|
13
13
|
|
14
14
|
|
15
|
-
__all__ = [AgentRServer, LocalServer, server_from_config]
|
15
|
+
__all__ = [AgentRServer, LocalServer, SingleMCPServer, server_from_config]
|
@@ -2,47 +2,6 @@
|
|
2
2
|
|
3
3
|
An MCP Server for the {{ name }} API.
|
4
4
|
|
5
|
-
## 📋 Prerequisites
|
6
|
-
|
7
|
-
Before you begin, ensure you have met the following requirements:
|
8
|
-
* Python 3.11+ (Recommended)
|
9
|
-
* [uv](https://github.com/astral-sh/uv) installed globally (`pip install uv`)
|
10
|
-
|
11
|
-
## 🛠️ Setup Instructions
|
12
|
-
|
13
|
-
Follow these steps to get the development environment up and running:
|
14
|
-
|
15
|
-
### 1. Sync Project Dependencies
|
16
|
-
Navigate to the project root directory (where `pyproject.toml` is located).
|
17
|
-
```bash
|
18
|
-
uv sync
|
19
|
-
```
|
20
|
-
This command uses `uv` to install all dependencies listed in `pyproject.toml` into a virtual environment (`.venv`) located in the project root.
|
21
|
-
|
22
|
-
### 2. Activate the Virtual Environment
|
23
|
-
Activating the virtual environment ensures that you are using the project's specific dependencies and Python interpreter.
|
24
|
-
- On **Linux/macOS**:
|
25
|
-
```bash
|
26
|
-
source .venv/bin/activate
|
27
|
-
```
|
28
|
-
- On **Windows**:
|
29
|
-
```bash
|
30
|
-
.venv\\Scripts\\activate
|
31
|
-
```
|
32
|
-
|
33
|
-
### 3. Start the MCP Inspector
|
34
|
-
Use the MCP CLI to start the application in development mode.
|
35
|
-
```bash
|
36
|
-
mcp dev src/{{ name.lower() }}/mcp.py
|
37
|
-
```
|
38
|
-
The MCP inspector should now be running. Check the console output for the exact address and port.
|
39
|
-
|
40
|
-
## 🔌 Supported Integrations
|
41
|
-
|
42
|
-
- AgentR
|
43
|
-
- API Key (Coming Soon)
|
44
|
-
- OAuth (Coming Soon)
|
45
|
-
|
46
5
|
## 🛠️ Tool List
|
47
6
|
|
48
7
|
This is automatically generated from OpenAPI schema for the {{ name }} API.
|
@@ -56,38 +15,3 @@ This is automatically generated from OpenAPI schema for the {{ name }} API.
|
|
56
15
|
{% else %}
|
57
16
|
No tools with documentation were found in this API client.
|
58
17
|
{% endif %}
|
59
|
-
|
60
|
-
## 📁 Project Structure
|
61
|
-
|
62
|
-
The generated project has a standard layout:
|
63
|
-
```
|
64
|
-
.
|
65
|
-
├── src/ # Source code directory
|
66
|
-
│ └── {{ name.lower() }}/
|
67
|
-
│ ├── __init__.py
|
68
|
-
│ └── mcp.py # Server is launched here
|
69
|
-
│ └── app.py # Application tools are defined here
|
70
|
-
├── tests/ # Directory for project tests
|
71
|
-
├── .env # Environment variables (for local development)
|
72
|
-
├── pyproject.toml # Project dependencies managed by uv
|
73
|
-
├── README.md # This file
|
74
|
-
```
|
75
|
-
|
76
|
-
## 📝 License
|
77
|
-
|
78
|
-
This project is licensed under the MIT License.
|
79
|
-
|
80
|
-
---
|
81
|
-
|
82
|
-
_This project was generated using **MCP CLI** — Happy coding! 🚀_
|
83
|
-
|
84
|
-
## Usage
|
85
|
-
|
86
|
-
- Login to AgentR
|
87
|
-
- Follow the quickstart guide to setup MCP Server for your client
|
88
|
-
- Visit Apps Store and enable the {{ name }} app
|
89
|
-
- Restart the MCP Server
|
90
|
-
|
91
|
-
### Local Development
|
92
|
-
|
93
|
-
- Follow the README to test with the local MCP Server
|
universal_mcp/tools/__init__.py
CHANGED
universal_mcp/tools/adapters.py
CHANGED
@@ -41,3 +41,17 @@ def convert_tool_to_langchain_tool(
|
|
41
41
|
coroutine=call_tool,
|
42
42
|
response_format="content",
|
43
43
|
)
|
44
|
+
|
45
|
+
|
46
|
+
def convert_tool_to_openai_tool(
|
47
|
+
tool: Tool,
|
48
|
+
):
|
49
|
+
"""Convert a Tool object to an OpenAI function."""
|
50
|
+
return {
|
51
|
+
"type": "function",
|
52
|
+
"function": {
|
53
|
+
"name": tool.name,
|
54
|
+
"description": tool.description,
|
55
|
+
"parameters": tool.parameters,
|
56
|
+
},
|
57
|
+
}
|
@@ -61,7 +61,7 @@ class ArgModelBase(BaseModel):
|
|
61
61
|
That is, sub-models etc are not dumped - they are kept as pydantic models.
|
62
62
|
"""
|
63
63
|
kwargs: dict[str, Any] = {}
|
64
|
-
for field_name in self.model_fields:
|
64
|
+
for field_name in self.__class__.model_fields:
|
65
65
|
kwargs[field_name] = getattr(self, field_name)
|
66
66
|
return kwargs
|
67
67
|
|
@@ -0,0 +1,180 @@
|
|
1
|
+
from collections.abc import Callable
|
2
|
+
from typing import Any, Literal
|
3
|
+
|
4
|
+
from loguru import logger
|
5
|
+
|
6
|
+
from universal_mcp.analytics import analytics
|
7
|
+
from universal_mcp.applications.application import BaseApplication
|
8
|
+
from universal_mcp.exceptions import ToolError
|
9
|
+
from universal_mcp.tools.adapters import (
|
10
|
+
convert_tool_to_langchain_tool,
|
11
|
+
convert_tool_to_mcp_tool,
|
12
|
+
convert_tool_to_openai_tool,
|
13
|
+
)
|
14
|
+
from universal_mcp.tools.tools import Tool
|
15
|
+
|
16
|
+
|
17
|
+
class ToolManager:
|
18
|
+
"""Manages FastMCP tools."""
|
19
|
+
|
20
|
+
def __init__(self, warn_on_duplicate_tools: bool = True):
|
21
|
+
self._tools: dict[str, Tool] = {}
|
22
|
+
self.warn_on_duplicate_tools = warn_on_duplicate_tools
|
23
|
+
|
24
|
+
def get_tool(self, name: str) -> Tool | None:
|
25
|
+
"""Get tool by name."""
|
26
|
+
return self._tools.get(name)
|
27
|
+
|
28
|
+
def list_tools(
|
29
|
+
self, format: Literal["mcp", "langchain", "openai"] = "mcp"
|
30
|
+
) -> list[Tool]:
|
31
|
+
"""List all registered tools."""
|
32
|
+
if format == "mcp":
|
33
|
+
return [convert_tool_to_mcp_tool(tool) for tool in self._tools.values()]
|
34
|
+
elif format == "langchain":
|
35
|
+
return [
|
36
|
+
convert_tool_to_langchain_tool(tool) for tool in self._tools.values()
|
37
|
+
]
|
38
|
+
elif format == "openai":
|
39
|
+
return [convert_tool_to_openai_tool(tool) for tool in self._tools.values()]
|
40
|
+
else:
|
41
|
+
raise ValueError(f"Invalid format: {format}")
|
42
|
+
|
43
|
+
# Modified add_tool to accept name override explicitly
|
44
|
+
def add_tool(
|
45
|
+
self, fn: Callable[..., Any] | Tool, name: str | None = None
|
46
|
+
) -> Tool: # Changed any to Any
|
47
|
+
"""Add a tool to the server, allowing name override."""
|
48
|
+
# Create the Tool object using the provided name if available
|
49
|
+
tool = fn if isinstance(fn, Tool) else Tool.from_function(fn, name=name)
|
50
|
+
existing = self._tools.get(tool.name)
|
51
|
+
if existing:
|
52
|
+
if self.warn_on_duplicate_tools:
|
53
|
+
# Check if it's the *exact* same function object being added again
|
54
|
+
if existing.fn is not tool.fn:
|
55
|
+
logger.warning(
|
56
|
+
f"Tool name '{tool.name}' conflicts with an existing tool. Skipping addition of new function."
|
57
|
+
)
|
58
|
+
else:
|
59
|
+
logger.debug(
|
60
|
+
f"Tool '{tool.name}' with the same function already exists."
|
61
|
+
)
|
62
|
+
return existing # Return the existing tool if name conflicts
|
63
|
+
|
64
|
+
logger.debug(f"Adding tool: {tool.name}")
|
65
|
+
self._tools[tool.name] = tool
|
66
|
+
return tool
|
67
|
+
|
68
|
+
async def call_tool(
|
69
|
+
self,
|
70
|
+
name: str,
|
71
|
+
arguments: dict[str, Any],
|
72
|
+
context=None,
|
73
|
+
) -> Any:
|
74
|
+
"""Call a tool by name with arguments."""
|
75
|
+
tool = self.get_tool(name)
|
76
|
+
if not tool:
|
77
|
+
raise ToolError(f"Unknown tool: {name}")
|
78
|
+
try:
|
79
|
+
result = await tool.run(arguments)
|
80
|
+
analytics.track_tool_called(name, "success")
|
81
|
+
return result
|
82
|
+
except Exception as e:
|
83
|
+
analytics.track_tool_called(name, "error", str(e))
|
84
|
+
raise
|
85
|
+
|
86
|
+
def get_tools_by_tags(self, tags: list[str]) -> list[Tool]:
|
87
|
+
"""Get tools by tags."""
|
88
|
+
return [
|
89
|
+
tool
|
90
|
+
for tool in self._tools.values()
|
91
|
+
if any(tag in tool.tags for tag in tags)
|
92
|
+
]
|
93
|
+
|
94
|
+
def register_tools_from_app(
|
95
|
+
self,
|
96
|
+
app: BaseApplication,
|
97
|
+
tools: list[str] | None = None,
|
98
|
+
tags: list[str] | None = None,
|
99
|
+
) -> None:
|
100
|
+
try:
|
101
|
+
available_tool_functions = app.list_tools()
|
102
|
+
except TypeError as e:
|
103
|
+
logger.error(f"Error calling list_tools for app '{app.name}'. Error: {e}")
|
104
|
+
return
|
105
|
+
except Exception as e:
|
106
|
+
logger.error(f"Failed to get tool list from app '{app.name}': {e}")
|
107
|
+
return
|
108
|
+
|
109
|
+
if not isinstance(available_tool_functions, list):
|
110
|
+
logger.error(
|
111
|
+
f"App '{app.name}' list_tools() did not return a list. Skipping registration."
|
112
|
+
)
|
113
|
+
return
|
114
|
+
|
115
|
+
# Determine the effective filter lists *before* the loop for efficiency
|
116
|
+
# Use an empty list if None is passed, simplifies checks later
|
117
|
+
tools_name_filter = tools or []
|
118
|
+
|
119
|
+
# For tags, determine the filter list based on priority: passed 'tags' or default 'important'
|
120
|
+
# This list is only used if tools_name_filter is empty.
|
121
|
+
active_tags_filter = tags if tags else ["important"] # Default filter
|
122
|
+
|
123
|
+
logger.debug(
|
124
|
+
f"Registering tools for '{app.name}'. Name filter: {tools_name_filter or 'None'}. Tag filter (if name filter empty): {active_tags_filter}"
|
125
|
+
)
|
126
|
+
|
127
|
+
for tool_func in available_tool_functions:
|
128
|
+
if not callable(tool_func):
|
129
|
+
logger.warning(
|
130
|
+
f"Item returned by {app.name}.list_tools() is not callable: {tool_func}. Skipping."
|
131
|
+
)
|
132
|
+
continue
|
133
|
+
|
134
|
+
try:
|
135
|
+
# Create the Tool metadata object from the function.
|
136
|
+
# This parses docstring (including tags), gets signature etc.
|
137
|
+
tool_instance = Tool.from_function(tool_func)
|
138
|
+
except Exception as e:
|
139
|
+
logger.error(
|
140
|
+
f"Failed to create Tool object from function '{getattr(tool_func, '__name__', 'unknown')}' in app '{app.name}': {e}"
|
141
|
+
)
|
142
|
+
continue # Skip this tool if metadata creation fails
|
143
|
+
|
144
|
+
# --- Modify the Tool instance before filtering/registration ---
|
145
|
+
original_name = tool_instance.name
|
146
|
+
prefixed_name = f"{app.name}_{original_name}"
|
147
|
+
tool_instance.name = prefixed_name # Update the name
|
148
|
+
|
149
|
+
# Add the app name itself as a tag for categorization
|
150
|
+
if app.name not in tool_instance.tags:
|
151
|
+
tool_instance.tags.append(app.name)
|
152
|
+
|
153
|
+
# --- Filtering Logic ---
|
154
|
+
should_register = False # Default to not registering
|
155
|
+
|
156
|
+
if tools_name_filter:
|
157
|
+
# --- Primary Filter: Check against specific tool names ---
|
158
|
+
if tool_instance.name in tools_name_filter:
|
159
|
+
should_register = True
|
160
|
+
logger.debug(f"Tool '{tool_instance.name}' matched name filter.")
|
161
|
+
# If not in the name filter, it's skipped (should_register remains False)
|
162
|
+
|
163
|
+
else:
|
164
|
+
# --- Secondary Filter: Check against tags (since tools_name_filter is empty) ---
|
165
|
+
# Check if *any* tag in active_tags_filter exists in the tool's tags
|
166
|
+
# tool_instance.tags includes tags parsed from the docstring + app.name
|
167
|
+
if any(tag in tool_instance.tags for tag in active_tags_filter):
|
168
|
+
should_register = True
|
169
|
+
logger.debug(
|
170
|
+
f"Tool '{tool_instance.name}' matched tag filter {active_tags_filter}."
|
171
|
+
)
|
172
|
+
# else:
|
173
|
+
# logger.debug(f"Tool '{tool_instance.name}' did NOT match tag filter {active_tags_filter}. Tool tags: {tool_instance.tags}")
|
174
|
+
|
175
|
+
# --- Add the tool if it passed the filters ---
|
176
|
+
if should_register:
|
177
|
+
# Pass the fully configured Tool *instance* to add_tool
|
178
|
+
self.add_tool(tool_instance)
|
179
|
+
# else: If not registered, optionally log it for debugging:
|
180
|
+
# logger.trace(f"Tool '{tool_instance.name}' skipped due to filters.") # Use trace level
|
universal_mcp/tools/tools.py
CHANGED
@@ -1,94 +1,16 @@
|
|
1
|
-
from __future__ import annotations as _annotations
|
2
|
-
|
3
1
|
import inspect
|
4
2
|
from collections.abc import Callable
|
5
|
-
from typing import Any
|
3
|
+
from typing import Any
|
6
4
|
|
7
5
|
import httpx
|
8
|
-
from loguru import logger
|
9
6
|
from pydantic import BaseModel, Field
|
10
7
|
|
11
|
-
from universal_mcp.analytics import analytics
|
12
|
-
from universal_mcp.applications import BaseApplication
|
13
8
|
from universal_mcp.exceptions import NotAuthorizedError, ToolError
|
14
9
|
from universal_mcp.utils.docstring_parser import parse_docstring
|
15
10
|
|
16
11
|
from .func_metadata import FuncMetadata
|
17
12
|
|
18
13
|
|
19
|
-
def convert_tool_to_openai_tool(
|
20
|
-
tool: Tool,
|
21
|
-
):
|
22
|
-
"""Convert a Tool object to an OpenAI function."""
|
23
|
-
return {
|
24
|
-
"type": "function",
|
25
|
-
"function": {
|
26
|
-
"name": tool.name,
|
27
|
-
"description": tool.description,
|
28
|
-
"parameters": tool.parameters,
|
29
|
-
},
|
30
|
-
}
|
31
|
-
|
32
|
-
|
33
|
-
def convert_tool_to_mcp_tool(
|
34
|
-
tool: Tool,
|
35
|
-
):
|
36
|
-
from mcp.server.fastmcp.server import MCPTool
|
37
|
-
|
38
|
-
return MCPTool(
|
39
|
-
name=tool.name,
|
40
|
-
description=tool.description or "",
|
41
|
-
inputSchema=tool.parameters,
|
42
|
-
)
|
43
|
-
|
44
|
-
|
45
|
-
def convert_tool_to_langchain_tool(
|
46
|
-
tool: Tool,
|
47
|
-
):
|
48
|
-
"""Convert a Tool object to a LangChain StructuredTool.
|
49
|
-
|
50
|
-
NOTE: this tool can be executed only in a context of an active MCP client session.
|
51
|
-
|
52
|
-
Args:
|
53
|
-
tool: Tool object to convert
|
54
|
-
|
55
|
-
Returns:
|
56
|
-
a LangChain StructuredTool
|
57
|
-
"""
|
58
|
-
from langchain_core.tools import ( # Keep import inside if preferred, or move top
|
59
|
-
StructuredTool,
|
60
|
-
ToolException,
|
61
|
-
)
|
62
|
-
|
63
|
-
async def call_tool(
|
64
|
-
**arguments: dict[
|
65
|
-
str, any
|
66
|
-
], # arguments received here are validated by StructuredTool
|
67
|
-
):
|
68
|
-
# tool.run already handles validation via fn_metadata.call_fn_with_arg_validation
|
69
|
-
# It should be able to handle the validated/coerced types from StructuredTool
|
70
|
-
try:
|
71
|
-
call_tool_result = await tool.run(arguments)
|
72
|
-
return call_tool_result
|
73
|
-
except ToolError as e:
|
74
|
-
# Langchain expects ToolException for controlled errors
|
75
|
-
raise ToolException(f"Error running tool '{tool.name}': {e}") from e
|
76
|
-
except Exception as e:
|
77
|
-
# Catch unexpected errors
|
78
|
-
raise ToolException(f"Unexpected error in tool '{tool.name}': {e}") from e
|
79
|
-
|
80
|
-
return StructuredTool(
|
81
|
-
name=tool.name,
|
82
|
-
description=tool.description
|
83
|
-
or f"Tool named {tool.name}.", # Provide fallback description
|
84
|
-
coroutine=call_tool,
|
85
|
-
args_schema=tool.fn_metadata.arg_model, # <<< --- ADD THIS LINE
|
86
|
-
# handle_tool_error=True, # Optional: Consider adding error handling config
|
87
|
-
# return_direct=False, # Optional: Default is usually fine
|
88
|
-
# response_format="content", # This field might not be valid for StructuredTool, check LangChain docs if needed. Let's remove for now.
|
89
|
-
)
|
90
|
-
|
91
|
-
|
92
14
|
class Tool(BaseModel):
|
93
15
|
"""Internal tool registration info."""
|
94
16
|
|
@@ -120,7 +42,7 @@ class Tool(BaseModel):
|
|
120
42
|
cls,
|
121
43
|
fn: Callable[..., Any],
|
122
44
|
name: str | None = None,
|
123
|
-
) -> Tool:
|
45
|
+
) -> "Tool":
|
124
46
|
"""Create a Tool from a function."""
|
125
47
|
|
126
48
|
func_name = name or fn.__name__
|
@@ -172,169 +94,3 @@ class Tool(BaseModel):
|
|
172
94
|
raise ToolError(message) from e
|
173
95
|
except Exception as e:
|
174
96
|
raise ToolError(f"Error executing tool {self.name}: {e}") from e
|
175
|
-
|
176
|
-
|
177
|
-
class ToolManager:
|
178
|
-
"""Manages FastMCP tools."""
|
179
|
-
|
180
|
-
def __init__(self, warn_on_duplicate_tools: bool = True):
|
181
|
-
self._tools: dict[str, Tool] = {}
|
182
|
-
self.warn_on_duplicate_tools = warn_on_duplicate_tools
|
183
|
-
|
184
|
-
def get_tool(self, name: str) -> Tool | None:
|
185
|
-
"""Get tool by name."""
|
186
|
-
return self._tools.get(name)
|
187
|
-
|
188
|
-
def list_tools(
|
189
|
-
self, format: Literal["mcp", "langchain", "openai"] = "mcp"
|
190
|
-
) -> list[Tool]:
|
191
|
-
"""List all registered tools."""
|
192
|
-
if format == "mcp":
|
193
|
-
return [convert_tool_to_mcp_tool(tool) for tool in self._tools.values()]
|
194
|
-
elif format == "langchain":
|
195
|
-
return [
|
196
|
-
convert_tool_to_langchain_tool(tool) for tool in self._tools.values()
|
197
|
-
]
|
198
|
-
elif format == "openai":
|
199
|
-
return [convert_tool_to_openai_tool(tool) for tool in self._tools.values()]
|
200
|
-
else:
|
201
|
-
raise ValueError(f"Invalid format: {format}")
|
202
|
-
|
203
|
-
# Modified add_tool to accept name override explicitly
|
204
|
-
def add_tool(
|
205
|
-
self, fn: Callable[..., Any] | Tool, name: str | None = None
|
206
|
-
) -> Tool: # Changed any to Any
|
207
|
-
"""Add a tool to the server, allowing name override."""
|
208
|
-
# Create the Tool object using the provided name if available
|
209
|
-
tool = fn if isinstance(fn, Tool) else Tool.from_function(fn, name=name)
|
210
|
-
existing = self._tools.get(tool.name)
|
211
|
-
if existing:
|
212
|
-
if self.warn_on_duplicate_tools:
|
213
|
-
# Check if it's the *exact* same function object being added again
|
214
|
-
if existing.fn is not tool.fn:
|
215
|
-
logger.warning(
|
216
|
-
f"Tool name '{tool.name}' conflicts with an existing tool. Skipping addition of new function."
|
217
|
-
)
|
218
|
-
else:
|
219
|
-
logger.debug(
|
220
|
-
f"Tool '{tool.name}' with the same function already exists."
|
221
|
-
)
|
222
|
-
return existing # Return the existing tool if name conflicts
|
223
|
-
|
224
|
-
logger.debug(f"Adding tool: {tool.name}")
|
225
|
-
self._tools[tool.name] = tool
|
226
|
-
return tool
|
227
|
-
|
228
|
-
async def call_tool(
|
229
|
-
self,
|
230
|
-
name: str,
|
231
|
-
arguments: dict[str, Any],
|
232
|
-
context=None,
|
233
|
-
) -> Any:
|
234
|
-
"""Call a tool by name with arguments."""
|
235
|
-
tool = self.get_tool(name)
|
236
|
-
if not tool:
|
237
|
-
raise ToolError(f"Unknown tool: {name}")
|
238
|
-
try:
|
239
|
-
result = await tool.run(arguments)
|
240
|
-
analytics.track_tool_called(name, "success")
|
241
|
-
return result
|
242
|
-
except Exception as e:
|
243
|
-
analytics.track_tool_called(name, "error", str(e))
|
244
|
-
raise
|
245
|
-
|
246
|
-
def get_tools_by_tags(self, tags: list[str]) -> list[Tool]:
|
247
|
-
"""Get tools by tags."""
|
248
|
-
return [
|
249
|
-
tool
|
250
|
-
for tool in self._tools.values()
|
251
|
-
if any(tag in tool.tags for tag in tags)
|
252
|
-
]
|
253
|
-
|
254
|
-
def register_tools_from_app(
|
255
|
-
self,
|
256
|
-
app: BaseApplication,
|
257
|
-
tools: list[str] | None = None,
|
258
|
-
tags: list[str] | None = None,
|
259
|
-
) -> None:
|
260
|
-
try:
|
261
|
-
available_tool_functions = app.list_tools()
|
262
|
-
except TypeError as e:
|
263
|
-
logger.error(f"Error calling list_tools for app '{app.name}'. Error: {e}")
|
264
|
-
return
|
265
|
-
except Exception as e:
|
266
|
-
logger.error(f"Failed to get tool list from app '{app.name}': {e}")
|
267
|
-
return
|
268
|
-
|
269
|
-
if not isinstance(available_tool_functions, list):
|
270
|
-
logger.error(
|
271
|
-
f"App '{app.name}' list_tools() did not return a list. Skipping registration."
|
272
|
-
)
|
273
|
-
return
|
274
|
-
|
275
|
-
# Determine the effective filter lists *before* the loop for efficiency
|
276
|
-
# Use an empty list if None is passed, simplifies checks later
|
277
|
-
tools_name_filter = tools or []
|
278
|
-
|
279
|
-
# For tags, determine the filter list based on priority: passed 'tags' or default 'important'
|
280
|
-
# This list is only used if tools_name_filter is empty.
|
281
|
-
active_tags_filter = tags if tags else ["important"] # Default filter
|
282
|
-
|
283
|
-
logger.debug(
|
284
|
-
f"Registering tools for '{app.name}'. Name filter: {tools_name_filter or 'None'}. Tag filter (if name filter empty): {active_tags_filter}"
|
285
|
-
)
|
286
|
-
|
287
|
-
for tool_func in available_tool_functions:
|
288
|
-
if not callable(tool_func):
|
289
|
-
logger.warning(
|
290
|
-
f"Item returned by {app.name}.list_tools() is not callable: {tool_func}. Skipping."
|
291
|
-
)
|
292
|
-
continue
|
293
|
-
|
294
|
-
try:
|
295
|
-
# Create the Tool metadata object from the function.
|
296
|
-
# This parses docstring (including tags), gets signature etc.
|
297
|
-
tool_instance = Tool.from_function(tool_func)
|
298
|
-
except Exception as e:
|
299
|
-
logger.error(
|
300
|
-
f"Failed to create Tool object from function '{getattr(tool_func, '__name__', 'unknown')}' in app '{app.name}': {e}"
|
301
|
-
)
|
302
|
-
continue # Skip this tool if metadata creation fails
|
303
|
-
|
304
|
-
# --- Modify the Tool instance before filtering/registration ---
|
305
|
-
original_name = tool_instance.name
|
306
|
-
prefixed_name = f"{app.name}_{original_name}"
|
307
|
-
tool_instance.name = prefixed_name # Update the name
|
308
|
-
|
309
|
-
# Add the app name itself as a tag for categorization
|
310
|
-
if app.name not in tool_instance.tags:
|
311
|
-
tool_instance.tags.append(app.name)
|
312
|
-
|
313
|
-
# --- Filtering Logic ---
|
314
|
-
should_register = False # Default to not registering
|
315
|
-
|
316
|
-
if tools_name_filter:
|
317
|
-
# --- Primary Filter: Check against specific tool names ---
|
318
|
-
if tool_instance.name in tools_name_filter:
|
319
|
-
should_register = True
|
320
|
-
logger.debug(f"Tool '{tool_instance.name}' matched name filter.")
|
321
|
-
# If not in the name filter, it's skipped (should_register remains False)
|
322
|
-
|
323
|
-
else:
|
324
|
-
# --- Secondary Filter: Check against tags (since tools_name_filter is empty) ---
|
325
|
-
# Check if *any* tag in active_tags_filter exists in the tool's tags
|
326
|
-
# tool_instance.tags includes tags parsed from the docstring + app.name
|
327
|
-
if any(tag in tool_instance.tags for tag in active_tags_filter):
|
328
|
-
should_register = True
|
329
|
-
logger.debug(
|
330
|
-
f"Tool '{tool_instance.name}' matched tag filter {active_tags_filter}."
|
331
|
-
)
|
332
|
-
# else:
|
333
|
-
# logger.debug(f"Tool '{tool_instance.name}' did NOT match tag filter {active_tags_filter}. Tool tags: {tool_instance.tags}")
|
334
|
-
|
335
|
-
# --- Add the tool if it passed the filters ---
|
336
|
-
if should_register:
|
337
|
-
# Pass the fully configured Tool *instance* to add_tool
|
338
|
-
self.add_tool(tool_instance)
|
339
|
-
# else: If not registered, optionally log it for debugging:
|
340
|
-
# logger.trace(f"Tool '{tool_instance.name}' skipped due to filters.") # Use trace level
|
@@ -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
|
64
|
+
"args": ["universal_mcp@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
|
93
|
+
"args": ["universal_mcp@latest", "run"],
|
94
94
|
"env": {"AGENTR_API_KEY": api_key},
|
95
95
|
}
|
96
96
|
|
@@ -120,7 +120,7 @@ def install_cline(api_key: str) -> None:
|
|
120
120
|
config["mcpServers"] = {}
|
121
121
|
config["mcpServers"]["universal_mcp"] = {
|
122
122
|
"command": get_uvx_path(),
|
123
|
-
"args": ["universal_mcp
|
123
|
+
"args": ["universal_mcp@latest", "run"],
|
124
124
|
"env": {"AGENTR_API_KEY": api_key},
|
125
125
|
}
|
126
126
|
|
@@ -156,7 +156,7 @@ def install_continue(api_key: str) -> None:
|
|
156
156
|
config["mcpServers"] = {}
|
157
157
|
config["mcpServers"]["universal_mcp"] = {
|
158
158
|
"command": get_uvx_path(),
|
159
|
-
"args": ["universal_mcp
|
159
|
+
"args": ["universal_mcp@latest", "run"],
|
160
160
|
"env": {"AGENTR_API_KEY": api_key},
|
161
161
|
}
|
162
162
|
|
@@ -192,7 +192,7 @@ def install_goose(api_key: str) -> None:
|
|
192
192
|
config["mcpServers"] = {}
|
193
193
|
config["mcpServers"]["universal_mcp"] = {
|
194
194
|
"command": get_uvx_path(),
|
195
|
-
"args": ["universal_mcp
|
195
|
+
"args": ["universal_mcp@latest", "run"],
|
196
196
|
"env": {"AGENTR_API_KEY": api_key},
|
197
197
|
}
|
198
198
|
|
@@ -228,7 +228,7 @@ def install_windsurf(api_key: str) -> None:
|
|
228
228
|
config["mcpServers"] = {}
|
229
229
|
config["mcpServers"]["universal_mcp"] = {
|
230
230
|
"command": get_uvx_path(),
|
231
|
-
"args": ["universal_mcp
|
231
|
+
"args": ["universal_mcp@latest", "run"],
|
232
232
|
"env": {"AGENTR_API_KEY": api_key},
|
233
233
|
}
|
234
234
|
|
@@ -267,7 +267,7 @@ def install_zed(api_key: str) -> None:
|
|
267
267
|
server.update(
|
268
268
|
{
|
269
269
|
"command": get_uvx_path(),
|
270
|
-
"args": ["universal_mcp
|
270
|
+
"args": ["universal_mcp@latest", "run"],
|
271
271
|
"env": {"AGENTR_API_KEY": api_key},
|
272
272
|
}
|
273
273
|
)
|
@@ -278,7 +278,7 @@ def install_zed(api_key: str) -> None:
|
|
278
278
|
{
|
279
279
|
"name": "universal_mcp",
|
280
280
|
"command": get_uvx_path(),
|
281
|
-
"args": ["universal_mcp
|
281
|
+
"args": ["universal_mcp@latest", "run"],
|
282
282
|
"env": {"AGENTR_API_KEY": api_key},
|
283
283
|
}
|
284
284
|
)
|
universal_mcp/utils/openapi.py
CHANGED
@@ -1,6 +1,5 @@
|
|
1
1
|
import json
|
2
2
|
import re
|
3
|
-
from functools import cache
|
4
3
|
from pathlib import Path
|
5
4
|
from typing import Any, Literal
|
6
5
|
|
@@ -80,65 +79,15 @@ def convert_to_snake_case(identifier: str) -> str:
|
|
80
79
|
return result.lower()
|
81
80
|
|
82
81
|
|
83
|
-
@cache
|
84
|
-
def _resolve_schema_reference(reference, schema):
|
85
|
-
"""
|
86
|
-
Resolve a JSON schema reference to its target schema.
|
87
|
-
|
88
|
-
Args:
|
89
|
-
reference (str): The reference string (e.g., '#/components/schemas/User')
|
90
|
-
schema (dict): The complete OpenAPI schema that contains the reference
|
91
|
-
|
92
|
-
Returns:
|
93
|
-
dict: The resolved schema, or None if not found
|
94
|
-
"""
|
95
|
-
if not reference.startswith("#/"):
|
96
|
-
return None
|
97
|
-
|
98
|
-
# Split the reference path and navigate through the schema
|
99
|
-
parts = reference[2:].split("/")
|
100
|
-
current = schema
|
101
|
-
|
102
|
-
for part in parts:
|
103
|
-
if part in current:
|
104
|
-
current = current[part]
|
105
|
-
else:
|
106
|
-
return None
|
107
|
-
|
108
|
-
return current
|
109
|
-
|
110
|
-
|
111
|
-
def _resolve_references(schema: dict[str, Any]):
|
112
|
-
"""
|
113
|
-
Recursively walk the OpenAPI schema and inline all JSON Schema $ref references.
|
114
|
-
"""
|
115
|
-
|
116
|
-
def _resolve(node):
|
117
|
-
if isinstance(node, dict):
|
118
|
-
# If this dict is a reference, replace it with the resolved schema
|
119
|
-
if "$ref" in node:
|
120
|
-
ref = node["$ref"]
|
121
|
-
resolved = _resolve_schema_reference(ref, schema)
|
122
|
-
# If resolution fails, leave the ref dict as-is
|
123
|
-
return _resolve(resolved) if resolved is not None else node
|
124
|
-
# Otherwise, recurse into each key/value
|
125
|
-
return {key: _resolve(value) for key, value in node.items()}
|
126
|
-
elif isinstance(node, list):
|
127
|
-
# Recurse into list elements
|
128
|
-
return [_resolve(item) for item in node]
|
129
|
-
# Primitive value, return as-is
|
130
|
-
return node
|
131
|
-
|
132
|
-
return _resolve(schema)
|
133
|
-
|
134
|
-
|
135
82
|
def _load_and_resolve_references(path: Path):
|
83
|
+
from jsonref import replace_refs
|
84
|
+
|
136
85
|
# Load the schema
|
137
86
|
type = "yaml" if path.suffix == ".yaml" else "json"
|
138
87
|
with open(path) as f:
|
139
88
|
schema = yaml.safe_load(f) if type == "yaml" else json.load(f)
|
140
89
|
# Resolve references
|
141
|
-
return
|
90
|
+
return replace_refs(schema)
|
142
91
|
|
143
92
|
|
144
93
|
def _determine_return_type(operation: dict[str, Any]) -> str:
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: universal-mcp
|
3
|
-
Version: 0.1.
|
3
|
+
Version: 0.1.15rc5
|
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
|
@@ -8,6 +8,7 @@ Requires-Python: >=3.11
|
|
8
8
|
Requires-Dist: cookiecutter>=2.6.0
|
9
9
|
Requires-Dist: gql[all]>=3.5.2
|
10
10
|
Requires-Dist: jinja2>=3.1.3
|
11
|
+
Requires-Dist: jsonref>=1.1.0
|
11
12
|
Requires-Dist: keyring>=25.6.0
|
12
13
|
Requires-Dist: litellm>=1.30.7
|
13
14
|
Requires-Dist: loguru>=0.7.3
|
@@ -1,9 +1,9 @@
|
|
1
1
|
universal_mcp/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
2
2
|
universal_mcp/analytics.py,sha256=aGCg0Okpcy06W70qCA9I8_ySOiCgAtzJAIWAdhBsOeA,2212
|
3
|
-
universal_mcp/cli.py,sha256=
|
4
|
-
universal_mcp/config.py,sha256=
|
3
|
+
universal_mcp/cli.py,sha256=XujnWHxz5NVxQjwu2BRHUcGC--Vp98M32n8hTsEqayI,8689
|
4
|
+
universal_mcp/config.py,sha256=xqz5VNxtk6Clepjw-aK-HrgMFQLzFuxiDb1fuHGpbxE,3717
|
5
5
|
universal_mcp/exceptions.py,sha256=WApedvzArNujD0gZfUofYBxjQo97ZDJLqDibtLWZoRk,373
|
6
|
-
universal_mcp/logger.py,sha256=
|
6
|
+
universal_mcp/logger.py,sha256=JtAC8ImO74lvt5xepV3W5BIz-u3nZOAY1ecdhmQhub0,2081
|
7
7
|
universal_mcp/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
8
8
|
universal_mcp/applications/__init__.py,sha256=ZjV0P55Ej0vjIQ_bCSNatWTX-VphDJ6OGePWBu3bu3U,3196
|
9
9
|
universal_mcp/applications/application.py,sha256=0eC9D4HHRwIGpuFusaCxTZ0u64U68VbBpRSxjxGB5y8,8152
|
@@ -11,29 +11,29 @@ universal_mcp/integrations/README.md,sha256=lTAPXO2nivcBe1q7JT6PRa6v9Ns_ZersQMId
|
|
11
11
|
universal_mcp/integrations/__init__.py,sha256=tg6Yk59AEhwPsrTp0hZQ3NBfmJuYGu2sNCOXuph-h9k,922
|
12
12
|
universal_mcp/integrations/integration.py,sha256=genBiaWuzCs-XCf3UD1j8PQYyGU3GiVO4uupSdJRHnA,12601
|
13
13
|
universal_mcp/servers/README.md,sha256=ytFlgp8-LO0oogMrHkMOp8SvFTwgsKgv7XhBVZGNTbM,2284
|
14
|
-
universal_mcp/servers/__init__.py,sha256=
|
14
|
+
universal_mcp/servers/__init__.py,sha256=etFrqXFODIl9oGeqQS-aUxzw4J6pjzYjHl4VPvQaR3A,508
|
15
15
|
universal_mcp/servers/server.py,sha256=0oJQQUiwPdG2q79tzsVv3WPMV5YIFbF14PRvBF-SxMQ,9395
|
16
16
|
universal_mcp/stores/README.md,sha256=jrPh_ow4ESH4BDGaSafilhOVaN8oQ9IFlFW-j5Z5hLA,2465
|
17
17
|
universal_mcp/stores/__init__.py,sha256=quvuwhZnpiSLuojf0NfmBx2xpaCulv3fbKtKaSCEmuM,603
|
18
18
|
universal_mcp/stores/store.py,sha256=lYaFd-9YKC404BPeqzNw_Xm3ziQjksZyvQtaW1yd9FM,6900
|
19
|
-
universal_mcp/templates/README.md.j2,sha256=
|
19
|
+
universal_mcp/templates/README.md.j2,sha256=Mrm181YX-o_-WEfKs01Bi2RJy43rBiq2j6fTtbWgbTA,401
|
20
20
|
universal_mcp/templates/api_client.py.j2,sha256=972Im7LNUAq3yZTfwDcgivnb-b8u6_JLKWXwoIwXXXQ,908
|
21
21
|
universal_mcp/tools/README.md,sha256=RuxliOFqV1ZEyeBdj3m8UKfkxAsfrxXh-b6V4ZGAk8I,2468
|
22
|
-
universal_mcp/tools/__init__.py,sha256=
|
23
|
-
universal_mcp/tools/adapters.py,sha256=
|
24
|
-
universal_mcp/tools/func_metadata.py,sha256=
|
25
|
-
universal_mcp/tools/
|
22
|
+
universal_mcp/tools/__init__.py,sha256=GgK8CAxskkoif4WCaXsjs4zTgqN9VcVMyQa2G2LaYJ4,92
|
23
|
+
universal_mcp/tools/adapters.py,sha256=oCF042vsOKP9bUT6000YbYEmOpOiroXEnMdDMs847CU,1235
|
24
|
+
universal_mcp/tools/func_metadata.py,sha256=bxcwwKkHcITZQXt1eal7rFr5NGCrC0SLSGZwOsyCVKo,7985
|
25
|
+
universal_mcp/tools/manager.py,sha256=Ne37cLlyk0oku0iUXbc6BmzNH-3a98oWcpe_nlyZRtU,7516
|
26
|
+
universal_mcp/tools/tools.py,sha256=2ddZdi618xTs36iM062Pc6cnIbVP17L4NRBMNiFuv1k,3306
|
26
27
|
universal_mcp/utils/__init__.py,sha256=8wi4PGWu-SrFjNJ8U7fr2iFJ1ktqlDmSKj1xYd7KSDc,41
|
27
28
|
universal_mcp/utils/agentr.py,sha256=3sobve7Odk8pIAZm3RHTX4Rc21rkBClcXQgXXslbSUA,3490
|
28
29
|
universal_mcp/utils/api_generator.py,sha256=x3LkJm3tXgl2qVQq-ZQW86w7IqbErEdFTfwBP3aOwyI,4763
|
29
30
|
universal_mcp/utils/docgen.py,sha256=zPmZ-b-fK6xk-dwHEx2hwShN-iquPD_O15CGuPwlj2k,21870
|
30
31
|
universal_mcp/utils/docstring_parser.py,sha256=j7aE-LLnBOPTJI0qXayf0NlYappzxICv5E_hUPNmAlc,11459
|
31
|
-
universal_mcp/utils/
|
32
|
-
universal_mcp/utils/
|
33
|
-
universal_mcp/utils/openapi.py,sha256=GhUdSefVOrOFMw15ZAIaEma-XKu6ptHv65ds0g7ntBo,24924
|
32
|
+
universal_mcp/utils/installation.py,sha256=1n5X_aIiuY8WNQn6Oji_gZ-aiRmNXxrg-qYRv-pGjxw,10195
|
33
|
+
universal_mcp/utils/openapi.py,sha256=fd4rfiT_hFmEHtzGg-tM3FMSAn-AT0EskKfOc71--jE,23317
|
34
34
|
universal_mcp/utils/readme.py,sha256=Q4E3RidWVg0ngYBGZCKcoA4eX6wlkCpvU-clU0E7Q20,3305
|
35
35
|
universal_mcp/utils/singleton.py,sha256=kolHnbS9yd5C7z-tzaUAD16GgI-thqJXysNi3sZM4No,733
|
36
|
-
universal_mcp-0.1.
|
37
|
-
universal_mcp-0.1.
|
38
|
-
universal_mcp-0.1.
|
39
|
-
universal_mcp-0.1.
|
36
|
+
universal_mcp-0.1.15rc5.dist-info/METADATA,sha256=OXwhLXpEIbkAsrFWmwx6aO_nhtHjjhif2yN-2kthT_M,9833
|
37
|
+
universal_mcp-0.1.15rc5.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
38
|
+
universal_mcp-0.1.15rc5.dist-info/entry_points.txt,sha256=QlBrVKmA2jIM0q-C-3TQMNJTTWOsOFQvgedBq2rZTS8,56
|
39
|
+
universal_mcp-0.1.15rc5.dist-info/RECORD,,
|
@@ -1,78 +0,0 @@
|
|
1
|
-
import csv
|
2
|
-
from pathlib import Path
|
3
|
-
|
4
|
-
from universal_mcp.applications import app_from_slug
|
5
|
-
|
6
|
-
|
7
|
-
def discover_available_app_slugs():
|
8
|
-
apps_dir = Path(__file__).resolve().parent.parent / "applications"
|
9
|
-
app_slugs = []
|
10
|
-
|
11
|
-
for item in apps_dir.iterdir():
|
12
|
-
if not item.is_dir() or item.name.startswith("_"):
|
13
|
-
continue
|
14
|
-
|
15
|
-
if (item / "app.py").exists():
|
16
|
-
slug = item.name.replace("_", "-")
|
17
|
-
app_slugs.append(slug)
|
18
|
-
|
19
|
-
return app_slugs
|
20
|
-
|
21
|
-
|
22
|
-
def extract_app_tools(app_slugs):
|
23
|
-
all_apps_tools = []
|
24
|
-
|
25
|
-
for slug in app_slugs:
|
26
|
-
try:
|
27
|
-
print(f"Loading app: {slug}")
|
28
|
-
app_class = app_from_slug(slug)
|
29
|
-
|
30
|
-
app_instance = app_class(integration=None)
|
31
|
-
|
32
|
-
tools = app_instance.list_tools()
|
33
|
-
|
34
|
-
for tool in tools:
|
35
|
-
tool_name = tool.__name__
|
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
|
-
|
50
|
-
except Exception as e:
|
51
|
-
print(f"Error loading app {slug}: {e}")
|
52
|
-
|
53
|
-
return all_apps_tools
|
54
|
-
|
55
|
-
|
56
|
-
def write_to_csv(app_tools, output_file="app_tools.csv"):
|
57
|
-
fieldnames = ["app_name", "tool_name", "description"]
|
58
|
-
|
59
|
-
with open(output_file, "w", newline="") as csvfile:
|
60
|
-
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
61
|
-
writer.writeheader()
|
62
|
-
writer.writerows(app_tools)
|
63
|
-
|
64
|
-
print(f"CSV file created: {output_file}")
|
65
|
-
|
66
|
-
|
67
|
-
def main():
|
68
|
-
app_slugs = discover_available_app_slugs()
|
69
|
-
print(f"Found {len(app_slugs)} app slugs: {', '.join(app_slugs)}")
|
70
|
-
|
71
|
-
app_tools = extract_app_tools(app_slugs)
|
72
|
-
print(f"Extracted {len(app_tools)} tools from all apps")
|
73
|
-
|
74
|
-
write_to_csv(app_tools)
|
75
|
-
|
76
|
-
|
77
|
-
if __name__ == "__main__":
|
78
|
-
main()
|
File without changes
|
File without changes
|