universal-mcp 0.1.0__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/__init__.py +2 -0
- universal_mcp/applications/__init__.py +31 -0
- universal_mcp/applications/agentr.py +0 -0
- universal_mcp/applications/application.py +101 -0
- universal_mcp/applications/github/app.py +354 -0
- universal_mcp/applications/google_calendar/app.py +487 -0
- universal_mcp/applications/google_mail/app.py +565 -0
- universal_mcp/applications/reddit/app.py +329 -0
- universal_mcp/applications/resend/app.py +43 -0
- universal_mcp/applications/tavily/app.py +57 -0
- universal_mcp/applications/zenquotes/app.py +21 -0
- universal_mcp/cli.py +111 -0
- universal_mcp/config.py +15 -0
- universal_mcp/exceptions.py +8 -0
- universal_mcp/integrations/README.md +25 -0
- universal_mcp/integrations/__init__.py +4 -0
- universal_mcp/integrations/agentr.py +87 -0
- universal_mcp/integrations/integration.py +141 -0
- universal_mcp/py.typed +0 -0
- universal_mcp/servers/__init__.py +3 -0
- universal_mcp/servers/server.py +134 -0
- universal_mcp/stores/__init__.py +3 -0
- universal_mcp/stores/store.py +71 -0
- universal_mcp/utils/bridge.py +0 -0
- universal_mcp/utils/openapi.py +274 -0
- universal_mcp-0.1.0.dist-info/METADATA +165 -0
- universal_mcp-0.1.0.dist-info/RECORD +29 -0
- universal_mcp-0.1.0.dist-info/WHEEL +4 -0
- universal_mcp-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,87 @@
|
|
1
|
+
from universal_mcp.integrations.integration import Integration
|
2
|
+
import os
|
3
|
+
import httpx
|
4
|
+
from loguru import logger
|
5
|
+
from universal_mcp.exceptions import NotAuthorizedError
|
6
|
+
|
7
|
+
class AgentRIntegration(Integration):
|
8
|
+
"""Integration class for AgentR API authentication and authorization.
|
9
|
+
|
10
|
+
This class handles API key authentication and OAuth authorization flow for AgentR services.
|
11
|
+
|
12
|
+
Args:
|
13
|
+
name (str): Name of the integration
|
14
|
+
api_key (str, optional): AgentR API key. If not provided, will look for AGENTR_API_KEY env var
|
15
|
+
**kwargs: Additional keyword arguments passed to parent Integration class
|
16
|
+
|
17
|
+
Raises:
|
18
|
+
ValueError: If no API key is provided or found in environment variables
|
19
|
+
"""
|
20
|
+
def __init__(self, name: str, api_key: str = None, **kwargs):
|
21
|
+
super().__init__(name, **kwargs)
|
22
|
+
self.api_key = api_key or os.getenv("AGENTR_API_KEY")
|
23
|
+
if not self.api_key:
|
24
|
+
logger.error("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.")
|
25
|
+
raise ValueError("AgentR API key required - get one at https://agentr.dev")
|
26
|
+
self.base_url = os.getenv("AGENTR_BASE_URL", "https://api.agentr.dev")
|
27
|
+
|
28
|
+
def set_credentials(self, credentials: dict| None = None):
|
29
|
+
"""Set credentials for the integration.
|
30
|
+
|
31
|
+
This method is not implemented for AgentR integration. Instead it redirects to the authorize flow.
|
32
|
+
|
33
|
+
Args:
|
34
|
+
credentials (dict | None, optional): Credentials dict (not used). Defaults to None.
|
35
|
+
|
36
|
+
Returns:
|
37
|
+
str: Authorization URL from authorize() method
|
38
|
+
"""
|
39
|
+
return self.authorize()
|
40
|
+
# raise NotImplementedError("AgentR Integration does not support setting credentials. Visit the authorize url to set credentials.")
|
41
|
+
|
42
|
+
def get_credentials(self):
|
43
|
+
"""Get credentials for the integration from the AgentR API.
|
44
|
+
|
45
|
+
Makes API request to retrieve stored credentials for this integration.
|
46
|
+
|
47
|
+
Returns:
|
48
|
+
dict: Credentials data from API response
|
49
|
+
|
50
|
+
Raises:
|
51
|
+
NotAuthorizedError: If credentials are not found (404 response)
|
52
|
+
HTTPError: For other API errors
|
53
|
+
"""
|
54
|
+
response = httpx.get(
|
55
|
+
f"{self.base_url}/api/{self.name}/credentials/",
|
56
|
+
headers={
|
57
|
+
"accept": "application/json",
|
58
|
+
"X-API-KEY": self.api_key
|
59
|
+
}
|
60
|
+
)
|
61
|
+
if response.status_code == 404:
|
62
|
+
action = self.authorize()
|
63
|
+
raise NotAuthorizedError(action)
|
64
|
+
response.raise_for_status()
|
65
|
+
data = response.json()
|
66
|
+
return data
|
67
|
+
|
68
|
+
def authorize(self):
|
69
|
+
"""Get authorization URL for the integration.
|
70
|
+
|
71
|
+
Makes API request to get OAuth authorization URL.
|
72
|
+
|
73
|
+
Returns:
|
74
|
+
str: Message containing authorization URL
|
75
|
+
|
76
|
+
Raises:
|
77
|
+
HTTPError: If API request fails
|
78
|
+
"""
|
79
|
+
response = httpx.get(
|
80
|
+
f"{self.base_url}/api/{self.name}/authorize/",
|
81
|
+
headers={
|
82
|
+
"X-API-KEY": self.api_key
|
83
|
+
}
|
84
|
+
)
|
85
|
+
response.raise_for_status()
|
86
|
+
url = response.json()
|
87
|
+
return f"Please authorize the application by clicking the link {url}"
|
@@ -0,0 +1,141 @@
|
|
1
|
+
from abc import ABC, abstractmethod
|
2
|
+
from universal_mcp.stores.store import Store
|
3
|
+
import httpx
|
4
|
+
|
5
|
+
class Integration(ABC):
|
6
|
+
"""Abstract base class for handling application integrations and authentication.
|
7
|
+
|
8
|
+
This class defines the interface for different types of integrations that handle
|
9
|
+
authentication and authorization with external services.
|
10
|
+
|
11
|
+
Args:
|
12
|
+
name: The name identifier for this integration
|
13
|
+
store: Optional Store instance for persisting credentials and other data
|
14
|
+
|
15
|
+
Attributes:
|
16
|
+
name: The name identifier for this integration
|
17
|
+
store: Store instance for persisting credentials and other data
|
18
|
+
"""
|
19
|
+
def __init__(self, name: str, store: Store = None):
|
20
|
+
self.name = name
|
21
|
+
self.store = store
|
22
|
+
|
23
|
+
@abstractmethod
|
24
|
+
def authorize(self):
|
25
|
+
"""Authorize the integration.
|
26
|
+
|
27
|
+
Returns:
|
28
|
+
str: Authorization URL.
|
29
|
+
"""
|
30
|
+
pass
|
31
|
+
|
32
|
+
@abstractmethod
|
33
|
+
def get_credentials(self):
|
34
|
+
"""Get credentials for the integration.
|
35
|
+
|
36
|
+
Returns:
|
37
|
+
dict: Credentials for the integration.
|
38
|
+
|
39
|
+
Raises:
|
40
|
+
NotAuthorizedError: If credentials are not found.
|
41
|
+
"""
|
42
|
+
pass
|
43
|
+
|
44
|
+
@abstractmethod
|
45
|
+
def set_credentials(self, credentials: dict):
|
46
|
+
"""Set credentials for the integration.
|
47
|
+
|
48
|
+
Args:
|
49
|
+
credentials: Credentials for the integration.
|
50
|
+
"""
|
51
|
+
pass
|
52
|
+
|
53
|
+
|
54
|
+
|
55
|
+
class ApiKeyIntegration(Integration):
|
56
|
+
def __init__(self, name: str, store: Store = None, **kwargs):
|
57
|
+
super().__init__(name, store, **kwargs)
|
58
|
+
|
59
|
+
def get_credentials(self):
|
60
|
+
credentials = self.store.get(self.name)
|
61
|
+
return credentials
|
62
|
+
|
63
|
+
def set_credentials(self, credentials: dict):
|
64
|
+
self.store.set(self.name, credentials)
|
65
|
+
|
66
|
+
def authorize(self):
|
67
|
+
return {"text": "Please configure the API Key for {self.name}"}
|
68
|
+
|
69
|
+
|
70
|
+
class OAuthIntegration(Integration):
|
71
|
+
def __init__(self, name: str, store: Store = None, client_id: str = None, client_secret: str = None, auth_url: str = None, token_url: str = None, scope: str = None, **kwargs):
|
72
|
+
super().__init__(name, store, **kwargs)
|
73
|
+
self.client_id = client_id
|
74
|
+
self.client_secret = client_secret
|
75
|
+
self.auth_url = auth_url
|
76
|
+
self.token_url = token_url
|
77
|
+
self.scope = scope
|
78
|
+
|
79
|
+
def get_credentials(self):
|
80
|
+
credentials = self.store.get(self.name)
|
81
|
+
if not credentials:
|
82
|
+
return None
|
83
|
+
return credentials
|
84
|
+
|
85
|
+
def set_credentials(self, credentials: dict):
|
86
|
+
if not credentials or not isinstance(credentials, dict):
|
87
|
+
raise ValueError("Invalid credentials format")
|
88
|
+
if 'access_token' not in credentials:
|
89
|
+
raise ValueError("Credentials must contain access_token")
|
90
|
+
self.store.set(self.name, credentials)
|
91
|
+
|
92
|
+
def authorize(self):
|
93
|
+
if not all([self.client_id, self.client_secret, self.auth_url, self.token_url]):
|
94
|
+
raise ValueError("Missing required OAuth configuration")
|
95
|
+
|
96
|
+
auth_params = {
|
97
|
+
'client_id': self.client_id,
|
98
|
+
'response_type': 'code',
|
99
|
+
'scope': self.scope,
|
100
|
+
}
|
101
|
+
|
102
|
+
return {
|
103
|
+
"url": self.auth_url,
|
104
|
+
"params": auth_params,
|
105
|
+
"client_secret": self.client_secret,
|
106
|
+
"token_url": self.token_url
|
107
|
+
}
|
108
|
+
|
109
|
+
def handle_callback(self, code: str):
|
110
|
+
if not all([self.client_id, self.client_secret, self.token_url]):
|
111
|
+
raise ValueError("Missing required OAuth configuration")
|
112
|
+
|
113
|
+
token_params = {
|
114
|
+
'client_id': self.client_id,
|
115
|
+
'client_secret': self.client_secret,
|
116
|
+
'code': code,
|
117
|
+
'grant_type': 'authorization_code'
|
118
|
+
}
|
119
|
+
|
120
|
+
response = httpx.post(self.token_url, data=token_params)
|
121
|
+
response.raise_for_status()
|
122
|
+
credentials = response.json()
|
123
|
+
self.store.set(self.name, credentials)
|
124
|
+
return credentials
|
125
|
+
|
126
|
+
def refresh_token(self):
|
127
|
+
if not all([self.client_id, self.client_secret, self.token_url]):
|
128
|
+
raise ValueError("Missing required OAuth configuration")
|
129
|
+
|
130
|
+
token_params = {
|
131
|
+
'client_id': self.client_id,
|
132
|
+
'client_secret': self.client_secret,
|
133
|
+
'grant_type': 'refresh_token',
|
134
|
+
'refresh_token': self.credentials['refresh_token']
|
135
|
+
}
|
136
|
+
|
137
|
+
response = httpx.post(self.token_url, data=token_params)
|
138
|
+
response.raise_for_status()
|
139
|
+
credentials = response.json()
|
140
|
+
self.store.set(self.name, credentials)
|
141
|
+
return credentials
|
universal_mcp/py.typed
ADDED
File without changes
|
@@ -0,0 +1,134 @@
|
|
1
|
+
from abc import ABC, abstractmethod
|
2
|
+
import httpx
|
3
|
+
from mcp.server.fastmcp import FastMCP
|
4
|
+
from universal_mcp.applications import app_from_name
|
5
|
+
from universal_mcp.exceptions import NotAuthorizedError
|
6
|
+
from universal_mcp.integrations import ApiKeyIntegration, AgentRIntegration
|
7
|
+
from universal_mcp.stores.store import EnvironmentStore, MemoryStore
|
8
|
+
from universal_mcp.config import AppConfig, IntegrationConfig, StoreConfig
|
9
|
+
from loguru import logger
|
10
|
+
import os
|
11
|
+
from typing import Any
|
12
|
+
from mcp.types import TextContent
|
13
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
14
|
+
|
15
|
+
class Server(FastMCP, ABC):
|
16
|
+
"""
|
17
|
+
Server is responsible for managing the applications and the store
|
18
|
+
It also acts as a router for the applications, and exposed to the client
|
19
|
+
|
20
|
+
"""
|
21
|
+
def __init__(self, name: str, description: str, **kwargs):
|
22
|
+
super().__init__(name, description, **kwargs)
|
23
|
+
|
24
|
+
@abstractmethod
|
25
|
+
def _load_apps(self):
|
26
|
+
pass
|
27
|
+
|
28
|
+
async def call_tool(self, name: str, arguments: dict[str, Any]):
|
29
|
+
"""Call a tool by name with arguments."""
|
30
|
+
try:
|
31
|
+
result = await super().call_tool(name, arguments)
|
32
|
+
return result
|
33
|
+
except ToolError as e:
|
34
|
+
raised_error = e.__cause__
|
35
|
+
if isinstance(raised_error, NotAuthorizedError):
|
36
|
+
return [TextContent(type="text", text=raised_error.message)]
|
37
|
+
else:
|
38
|
+
raise e
|
39
|
+
|
40
|
+
class LocalServer(Server):
|
41
|
+
"""
|
42
|
+
Local server for development purposes
|
43
|
+
"""
|
44
|
+
def __init__(self, name: str, description: str, apps_list: list[AppConfig] = [], **kwargs):
|
45
|
+
super().__init__(name, description=description, **kwargs)
|
46
|
+
self.apps_list = [AppConfig.model_validate(app) for app in apps_list]
|
47
|
+
self._load_apps()
|
48
|
+
|
49
|
+
def _get_store(self, store_config: StoreConfig):
|
50
|
+
if store_config.type == "memory":
|
51
|
+
return MemoryStore()
|
52
|
+
elif store_config.type == "environment":
|
53
|
+
return EnvironmentStore()
|
54
|
+
return None
|
55
|
+
|
56
|
+
def _get_integration(self, integration_config: IntegrationConfig):
|
57
|
+
if not integration_config:
|
58
|
+
return None
|
59
|
+
if integration_config.type == "api_key":
|
60
|
+
store = self._get_store(integration_config.store)
|
61
|
+
integration = ApiKeyIntegration(integration_config.name, store=store)
|
62
|
+
if integration_config.credentials:
|
63
|
+
integration.set_credentials(integration_config.credentials)
|
64
|
+
return integration
|
65
|
+
elif integration_config.type == "agentr":
|
66
|
+
integration = AgentRIntegration(integration_config.name, api_key=integration_config.credentials.get("api_key") if integration_config.credentials else None)
|
67
|
+
return integration
|
68
|
+
return None
|
69
|
+
|
70
|
+
def _load_app(self, app_config: AppConfig):
|
71
|
+
name = app_config.name
|
72
|
+
integration = self._get_integration(app_config.integration)
|
73
|
+
app = app_from_name(name)(integration=integration)
|
74
|
+
return app
|
75
|
+
|
76
|
+
def _load_apps(self):
|
77
|
+
logger.info(f"Loading apps: {self.apps_list}")
|
78
|
+
for app_config in self.apps_list:
|
79
|
+
app = self._load_app(app_config)
|
80
|
+
if app:
|
81
|
+
tools = app.list_tools()
|
82
|
+
for tool in tools:
|
83
|
+
name = app.name + "_" + tool.__name__
|
84
|
+
description = tool.__doc__
|
85
|
+
self.add_tool(tool, name=name, description=description)
|
86
|
+
|
87
|
+
|
88
|
+
|
89
|
+
class AgentRServer(Server):
|
90
|
+
"""
|
91
|
+
AgentR server. Connects to the AgentR API to get the apps and tools. Only supports agentr integrations.
|
92
|
+
"""
|
93
|
+
def __init__(self, name: str, description: str, api_key: str | None = None, **kwargs):
|
94
|
+
super().__init__(name, description=description, **kwargs)
|
95
|
+
self.api_key = api_key or os.getenv("AGENTR_API_KEY")
|
96
|
+
self.base_url = os.getenv("AGENTR_BASE_URL", "https://api.agentr.dev")
|
97
|
+
if not self.api_key:
|
98
|
+
raise ValueError("API key required - get one at https://agentr.dev")
|
99
|
+
self._load_apps()
|
100
|
+
|
101
|
+
def _load_app(self, app_config: AppConfig):
|
102
|
+
name = app_config.name
|
103
|
+
if app_config.integration:
|
104
|
+
integration_name = app_config.integration.name
|
105
|
+
integration = AgentRIntegration(integration_name, api_key=self.api_key)
|
106
|
+
else:
|
107
|
+
integration = None
|
108
|
+
app = app_from_name(name)(integration=integration)
|
109
|
+
return app
|
110
|
+
|
111
|
+
def _list_apps_with_integrations(self):
|
112
|
+
# TODO: get this from the API
|
113
|
+
response = httpx.get(
|
114
|
+
f"{self.base_url}/api/apps/",
|
115
|
+
headers={
|
116
|
+
"X-API-KEY": self.api_key
|
117
|
+
}
|
118
|
+
)
|
119
|
+
response.raise_for_status()
|
120
|
+
apps = response.json()
|
121
|
+
|
122
|
+
logger.info(f"Apps: {apps}")
|
123
|
+
return [AppConfig.model_validate(app) for app in apps]
|
124
|
+
|
125
|
+
def _load_apps(self):
|
126
|
+
apps = self._list_apps_with_integrations()
|
127
|
+
for app in apps:
|
128
|
+
app = self._load_app(app)
|
129
|
+
if app:
|
130
|
+
tools = app.list_tools()
|
131
|
+
for tool in tools:
|
132
|
+
name = app.name + "_" + tool.__name__
|
133
|
+
description = tool.__doc__
|
134
|
+
self.add_tool(tool, name=name, description=description)
|
@@ -0,0 +1,71 @@
|
|
1
|
+
import os
|
2
|
+
from abc import ABC, abstractmethod
|
3
|
+
|
4
|
+
|
5
|
+
class Store(ABC):
|
6
|
+
@abstractmethod
|
7
|
+
def get(self, key: str):
|
8
|
+
pass
|
9
|
+
|
10
|
+
@abstractmethod
|
11
|
+
def set(self, key: str, value: str):
|
12
|
+
pass
|
13
|
+
|
14
|
+
@abstractmethod
|
15
|
+
def delete(self, key: str):
|
16
|
+
pass
|
17
|
+
|
18
|
+
class MemoryStore:
|
19
|
+
"""
|
20
|
+
Acts as credential store for the applications.
|
21
|
+
Responsible for storing and retrieving credentials.
|
22
|
+
Ideally should be a key value store
|
23
|
+
"""
|
24
|
+
def __init__(self):
|
25
|
+
self.data = {}
|
26
|
+
|
27
|
+
def get(self, key: str):
|
28
|
+
return self.data.get(key)
|
29
|
+
|
30
|
+
def set(self, key: str, value: str):
|
31
|
+
self.data[key] = value
|
32
|
+
|
33
|
+
def delete(self, key: str):
|
34
|
+
del self.data[key]
|
35
|
+
|
36
|
+
|
37
|
+
class EnvironmentStore(Store):
|
38
|
+
"""
|
39
|
+
Store that uses environment variables to store credentials.
|
40
|
+
"""
|
41
|
+
def __init__(self):
|
42
|
+
pass
|
43
|
+
|
44
|
+
def get(self, key: str):
|
45
|
+
return {"api_key": os.getenv(key)}
|
46
|
+
|
47
|
+
def set(self, key: str, value: str):
|
48
|
+
os.environ[key] = value
|
49
|
+
|
50
|
+
def delete(self, key: str):
|
51
|
+
del os.environ[key]
|
52
|
+
|
53
|
+
class RedisStore(Store):
|
54
|
+
"""
|
55
|
+
Store that uses a redis database to store credentials.
|
56
|
+
"""
|
57
|
+
def __init__(self, host: str, port: int, db: int):
|
58
|
+
import redis
|
59
|
+
self.host = host
|
60
|
+
self.port = port
|
61
|
+
self.db = db
|
62
|
+
self.redis = redis.Redis(host=self.host, port=self.port, db=self.db)
|
63
|
+
|
64
|
+
def get(self, key: str):
|
65
|
+
return self.redis.get(key)
|
66
|
+
|
67
|
+
def set(self, key: str, value: str):
|
68
|
+
self.redis.set(key, value)
|
69
|
+
|
70
|
+
def delete(self, key: str):
|
71
|
+
self.redis.delete(key)
|
File without changes
|