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,329 @@
|
|
1
|
+
import httpx
|
2
|
+
from universal_mcp.applications.application import APIApplication
|
3
|
+
from universal_mcp.integrations import Integration
|
4
|
+
from universal_mcp.exceptions import NotAuthorizedError
|
5
|
+
from loguru import logger
|
6
|
+
|
7
|
+
class RedditApp(APIApplication):
|
8
|
+
def __init__(self, integration: Integration) -> None:
|
9
|
+
super().__init__(name="reddit", integration=integration)
|
10
|
+
self.base_api_url = "https://oauth.reddit.com"
|
11
|
+
|
12
|
+
def _post(self, url, data):
|
13
|
+
try:
|
14
|
+
headers = self._get_headers()
|
15
|
+
response = httpx.post(url, headers=headers, data=data)
|
16
|
+
response.raise_for_status()
|
17
|
+
return response
|
18
|
+
except NotAuthorizedError as e:
|
19
|
+
logger.warning(f"Authorization needed: {e.message}")
|
20
|
+
raise e
|
21
|
+
except httpx.HTTPStatusError as e:
|
22
|
+
if e.response.status_code == 429:
|
23
|
+
return e.response.text or "Rate limit exceeded. Please try again later."
|
24
|
+
else:
|
25
|
+
raise e
|
26
|
+
except Exception as e:
|
27
|
+
logger.error(f"Error posting {url}: {e}")
|
28
|
+
raise e
|
29
|
+
|
30
|
+
def _get_headers(self):
|
31
|
+
if not self.integration:
|
32
|
+
raise ValueError("Integration not configured for RedditApp")
|
33
|
+
credentials = self.integration.get_credentials()
|
34
|
+
if "access_token" not in credentials:
|
35
|
+
logger.error("Reddit credentials found but missing 'access_token'.")
|
36
|
+
raise ValueError("Invalid Reddit credentials format.")
|
37
|
+
|
38
|
+
return {
|
39
|
+
"Authorization": f"Bearer {credentials['access_token']}",
|
40
|
+
"User-Agent": "agentr-reddit-app/0.1 by AgentR"
|
41
|
+
}
|
42
|
+
|
43
|
+
def get_subreddit_posts(self, subreddit: str, limit: int = 5, timeframe: str = "day") -> str:
|
44
|
+
"""Get the top posts from a specified subreddit over a given timeframe.
|
45
|
+
|
46
|
+
Args:
|
47
|
+
subreddit: The name of the subreddit (e.g., 'python', 'worldnews') without the 'r/'.
|
48
|
+
limit: The maximum number of posts to return (default: 5, max: 100).
|
49
|
+
timeframe: The time period for top posts. Valid options: 'hour', 'day', 'week', 'month', 'year', 'all' (default: 'day').
|
50
|
+
|
51
|
+
Returns:
|
52
|
+
A formatted string listing the top posts or an error message.
|
53
|
+
"""
|
54
|
+
valid_timeframes = ['hour', 'day', 'week', 'month', 'year', 'all']
|
55
|
+
if timeframe not in valid_timeframes:
|
56
|
+
return f"Error: Invalid timeframe '{timeframe}'. Please use one of: {', '.join(valid_timeframes)}"
|
57
|
+
|
58
|
+
if not 1 <= limit <= 100:
|
59
|
+
return f"Error: Invalid limit '{limit}'. Please use a value between 1 and 100."
|
60
|
+
|
61
|
+
|
62
|
+
url = f"{self.base_api_url}/r/{subreddit}/top"
|
63
|
+
params = {
|
64
|
+
"limit": limit,
|
65
|
+
"t": timeframe
|
66
|
+
}
|
67
|
+
|
68
|
+
logger.info(f"Requesting top {limit} posts from r/{subreddit} for timeframe '{timeframe}'")
|
69
|
+
response = self._get(url, params=params)
|
70
|
+
|
71
|
+
data = response.json()
|
72
|
+
|
73
|
+
if "error" in data:
|
74
|
+
logger.error(f"Reddit API error: {data['error']} - {data.get('message', '')}")
|
75
|
+
return f"Error from Reddit API: {data['error']} - {data.get('message', '')}"
|
76
|
+
|
77
|
+
posts = data.get("data", {}).get("children", [])
|
78
|
+
|
79
|
+
if not posts:
|
80
|
+
return f"No top posts found in r/{subreddit} for the timeframe '{timeframe}'."
|
81
|
+
|
82
|
+
result_lines = [f"Top {len(posts)} posts from r/{subreddit} (timeframe: {timeframe}):\n"]
|
83
|
+
for i, post_container in enumerate(posts):
|
84
|
+
post = post_container.get("data", {})
|
85
|
+
title = post.get('title', 'No Title')
|
86
|
+
score = post.get('score', 0)
|
87
|
+
author = post.get('author', 'Unknown Author')
|
88
|
+
permalink = post.get('permalink', '')
|
89
|
+
full_url = f"https://www.reddit.com{permalink}" if permalink else "No Link"
|
90
|
+
|
91
|
+
result_lines.append(f"{i+1}. \"{title}\" by u/{author} (Score: {score})")
|
92
|
+
result_lines.append(f" Link: {full_url}")
|
93
|
+
|
94
|
+
return "\n".join(result_lines)
|
95
|
+
|
96
|
+
|
97
|
+
def search_subreddits(self, query: str, limit: int = 5, sort: str = "relevance") -> str:
|
98
|
+
"""Search for subreddits matching a query string.
|
99
|
+
|
100
|
+
Args:
|
101
|
+
query: The text to search for in subreddit names and descriptions.
|
102
|
+
limit: The maximum number of subreddits to return (default: 5, max: 100).
|
103
|
+
sort: The order of results. Valid options: 'relevance', 'activity' (default: 'relevance').
|
104
|
+
|
105
|
+
Returns:
|
106
|
+
A formatted string listing the found subreddits and their descriptions, or an error message.
|
107
|
+
"""
|
108
|
+
valid_sorts = ['relevance', 'activity']
|
109
|
+
if sort not in valid_sorts:
|
110
|
+
return f"Error: Invalid sort option '{sort}'. Please use one of: {', '.join(valid_sorts)}"
|
111
|
+
|
112
|
+
if not 1 <= limit <= 100:
|
113
|
+
return f"Error: Invalid limit '{limit}'. Please use a value between 1 and 100."
|
114
|
+
|
115
|
+
|
116
|
+
url = f"{self.base_api_url}/subreddits/search"
|
117
|
+
params = {
|
118
|
+
"q": query,
|
119
|
+
"limit": limit,
|
120
|
+
"sort": sort,
|
121
|
+
# Optionally include NSFW results? Defaulting to false for safety.
|
122
|
+
# "include_over_18": "false"
|
123
|
+
}
|
124
|
+
|
125
|
+
logger.info(f"Searching for subreddits matching '{query}' (limit: {limit}, sort: {sort})")
|
126
|
+
response = self._get(url, params=params)
|
127
|
+
|
128
|
+
data = response.json()
|
129
|
+
|
130
|
+
if "error" in data:
|
131
|
+
logger.error(f"Reddit API error during subreddit search: {data['error']} - {data.get('message', '')}")
|
132
|
+
return f"Error from Reddit API during search: {data['error']} - {data.get('message', '')}"
|
133
|
+
|
134
|
+
subreddits = data.get("data", {}).get("children", [])
|
135
|
+
|
136
|
+
if not subreddits:
|
137
|
+
return f"No subreddits found matching the query '{query}'."
|
138
|
+
|
139
|
+
result_lines = [f"Found {len(subreddits)} subreddits matching '{query}' (sorted by {sort}):\n"]
|
140
|
+
for i, sub_container in enumerate(subreddits):
|
141
|
+
sub_data = sub_container.get("data", {})
|
142
|
+
display_name = sub_data.get('display_name', 'N/A') # e.g., 'python'
|
143
|
+
title = sub_data.get('title', 'No Title') # Often the same as display_name or slightly longer
|
144
|
+
subscribers = sub_data.get('subscribers', 0)
|
145
|
+
# Use public_description if available, fallback to title
|
146
|
+
description = sub_data.get('public_description', '').strip() or title
|
147
|
+
|
148
|
+
# Format subscriber count nicely
|
149
|
+
subscriber_str = f"{subscribers:,}" if subscribers else "Unknown"
|
150
|
+
|
151
|
+
result_lines.append(f"{i+1}. r/{display_name} ({subscriber_str} subscribers)")
|
152
|
+
if description:
|
153
|
+
result_lines.append(f" Description: {description}")
|
154
|
+
|
155
|
+
return "\n".join(result_lines)
|
156
|
+
|
157
|
+
def get_post_flairs(self, subreddit: str):
|
158
|
+
"""Retrieve the list of available post flairs for a specific subreddit.
|
159
|
+
|
160
|
+
Args:
|
161
|
+
subreddit: The name of the subreddit (e.g., 'python', 'worldnews') without the 'r/'.
|
162
|
+
|
163
|
+
Returns:
|
164
|
+
A list of dictionaries containing flair details, or an error message.
|
165
|
+
"""
|
166
|
+
|
167
|
+
url = f"{self.base_api_url}/r/{subreddit}/api/link_flair_v2"
|
168
|
+
|
169
|
+
logger.info(f"Fetching post flairs for subreddit: r/{subreddit}")
|
170
|
+
response = self._get(url)
|
171
|
+
|
172
|
+
flairs = response.json()
|
173
|
+
if not flairs:
|
174
|
+
return f"No post flairs available for r/{subreddit}."
|
175
|
+
|
176
|
+
return flairs
|
177
|
+
|
178
|
+
def create_post(self, subreddit: str, title: str, kind: str = "self", text: str = None, url: str = None, flair_id: str = None):
|
179
|
+
"""Create a new post in a specified subreddit.
|
180
|
+
|
181
|
+
Args:
|
182
|
+
subreddit: The name of the subreddit (e.g., 'python', 'worldnews') without the 'r/'.
|
183
|
+
title: The title of the post.
|
184
|
+
kind: The type of post; either 'self' (text post) or 'link' (link or image post).
|
185
|
+
text: The text content of the post; required if kind is 'self'.
|
186
|
+
url: The URL of the link or image; required if kind is 'link'.
|
187
|
+
For image posts to be displayed correctly, the URL must directly point to an image file
|
188
|
+
and end with a valid image extension (e.g., .jpg, .png, or .gif).
|
189
|
+
Note that .gif support can be inconsistent.
|
190
|
+
flair_id: The ID of the flair to assign to the post.
|
191
|
+
|
192
|
+
Returns:
|
193
|
+
The JSON response from the Reddit API, or an error message as a string.
|
194
|
+
If the reddit api returns an error within the json response, that error will be returned as a string.
|
195
|
+
"""
|
196
|
+
|
197
|
+
if kind not in ["self", "link"]:
|
198
|
+
raise ValueError("Invalid post kind. Must be one of 'self' or 'link'.")
|
199
|
+
|
200
|
+
if kind == "self" and not text:
|
201
|
+
raise ValueError("Text content is required for text posts.")
|
202
|
+
if kind == "link" and not url:
|
203
|
+
raise ValueError("URL is required for link posts (including images).")
|
204
|
+
|
205
|
+
data = {
|
206
|
+
"sr": subreddit,
|
207
|
+
"title": title,
|
208
|
+
"kind": kind,
|
209
|
+
"text": text,
|
210
|
+
"url": url,
|
211
|
+
"flair_id": flair_id,
|
212
|
+
}
|
213
|
+
data = {k: v for k, v in data.items() if v is not None}
|
214
|
+
|
215
|
+
url_api = f"{self.base_api_url}/api/submit"
|
216
|
+
logger.info(f"Submitting a new post to r/{subreddit}")
|
217
|
+
response = self._post(url_api, data=data)
|
218
|
+
response_json = response.json()
|
219
|
+
|
220
|
+
# Check for Reddit API errors in the response
|
221
|
+
if response_json and "json" in response_json and "errors" in response_json["json"]:
|
222
|
+
errors = response_json["json"]["errors"]
|
223
|
+
if errors:
|
224
|
+
error_message = ", ".join([f"{code}: {message}" for code, message in errors])
|
225
|
+
return f"Reddit API error: {error_message}"
|
226
|
+
|
227
|
+
return response_json
|
228
|
+
|
229
|
+
def get_comment_by_id(self, comment_id: str) -> dict:
|
230
|
+
"""
|
231
|
+
Retrieve a specific Reddit comment by its full ID (t1_commentid).
|
232
|
+
|
233
|
+
Args:
|
234
|
+
comment_id: The full unique ID of the comment (e.g., 't1_abcdef').
|
235
|
+
|
236
|
+
Returns:
|
237
|
+
A dictionary containing the comment data, or an error message if retrieval fails.
|
238
|
+
"""
|
239
|
+
|
240
|
+
# Define the endpoint URL
|
241
|
+
url = f"https://oauth.reddit.com/api/info.json?id={comment_id}"
|
242
|
+
|
243
|
+
# Make the GET request to the Reddit API
|
244
|
+
|
245
|
+
response = self._get(url)
|
246
|
+
|
247
|
+
data = response.json()
|
248
|
+
comments = data.get("data", {}).get("children", [])
|
249
|
+
if comments:
|
250
|
+
return comments[0]["data"]
|
251
|
+
else:
|
252
|
+
return {"error": "Comment not found."}
|
253
|
+
|
254
|
+
def post_comment(self, parent_id: str, text: str) -> dict:
|
255
|
+
"""
|
256
|
+
Post a comment to a Reddit post or another comment.
|
257
|
+
|
258
|
+
Args:
|
259
|
+
parent_id: The full ID of the parent comment or post (e.g., 't3_abc123' for a post, 't1_def456' for a comment).
|
260
|
+
text: The text content of the comment.
|
261
|
+
|
262
|
+
Returns:
|
263
|
+
A dictionary containing the response from the Reddit API, or an error message if posting fails.
|
264
|
+
"""
|
265
|
+
|
266
|
+
url = f"{self.base_api_url}/api/comment"
|
267
|
+
data = {
|
268
|
+
"parent": parent_id,
|
269
|
+
"text": text,
|
270
|
+
}
|
271
|
+
|
272
|
+
logger.info(f"Posting comment to {parent_id}")
|
273
|
+
response = self._post(url, data=data)
|
274
|
+
|
275
|
+
return response.json()
|
276
|
+
|
277
|
+
def edit_content(self, content_id: str, text: str) -> dict:
|
278
|
+
"""
|
279
|
+
Edit the text content of a Reddit post or comment.
|
280
|
+
|
281
|
+
Args:
|
282
|
+
content_id: The full ID of the content to edit (e.g., 't3_abc123' for a post, 't1_def456' for a comment).
|
283
|
+
text: The new text content.
|
284
|
+
|
285
|
+
Returns:
|
286
|
+
A dictionary containing the response from the Reddit API, or an error message if editing fails.
|
287
|
+
"""
|
288
|
+
|
289
|
+
url = f"{self.base_api_url}/api/editusertext"
|
290
|
+
data = {
|
291
|
+
"thing_id": content_id,
|
292
|
+
"text": text,
|
293
|
+
}
|
294
|
+
|
295
|
+
logger.info(f"Editing content {content_id}")
|
296
|
+
response = self._post(url, data=data)
|
297
|
+
|
298
|
+
return response.json()
|
299
|
+
|
300
|
+
|
301
|
+
def delete_content(self, content_id: str) -> dict:
|
302
|
+
"""
|
303
|
+
Delete a Reddit post or comment.
|
304
|
+
|
305
|
+
Args:
|
306
|
+
content_id: The full ID of the content to delete (e.g., 't3_abc123' for a post, 't1_def456' for a comment).
|
307
|
+
|
308
|
+
Returns:
|
309
|
+
A dictionary containing the response from the Reddit API, or an error message if deletion fails.
|
310
|
+
"""
|
311
|
+
|
312
|
+
url = f"{self.base_api_url}/api/del"
|
313
|
+
data = {
|
314
|
+
"id": content_id,
|
315
|
+
}
|
316
|
+
|
317
|
+
logger.info(f"Deleting content {content_id}")
|
318
|
+
response = self._post(url, data=data)
|
319
|
+
response.raise_for_status()
|
320
|
+
|
321
|
+
# Reddit's delete endpoint returns an empty response on success.
|
322
|
+
# We'll just return a success message.
|
323
|
+
return {"message": f"Content {content_id} deleted successfully."}
|
324
|
+
|
325
|
+
def list_tools(self):
|
326
|
+
return [
|
327
|
+
self.get_subreddit_posts, self.search_subreddits, self.get_post_flairs, self.create_post,
|
328
|
+
self.get_comment_by_id, self.post_comment, self.edit_content, self.delete_content
|
329
|
+
]
|
@@ -0,0 +1,43 @@
|
|
1
|
+
from universal_mcp.applications.application import APIApplication
|
2
|
+
from universal_mcp.integrations import Integration
|
3
|
+
|
4
|
+
class ResendApp(APIApplication):
|
5
|
+
def __init__(self, integration: Integration) -> None:
|
6
|
+
super().__init__(name="resend", integration=integration)
|
7
|
+
|
8
|
+
def _get_headers(self):
|
9
|
+
credentials = self.integration.get_credentials()
|
10
|
+
if not credentials:
|
11
|
+
raise ValueError("No credentials found")
|
12
|
+
return {
|
13
|
+
"Authorization": f"Bearer {credentials['api_key']}",
|
14
|
+
}
|
15
|
+
|
16
|
+
def send_email(self, to: str, subject: str, content: str) -> str:
|
17
|
+
"""Send an email using the Resend API
|
18
|
+
|
19
|
+
Args:
|
20
|
+
to: The email address to send the email to
|
21
|
+
subject: The subject of the email
|
22
|
+
content: The content of the email
|
23
|
+
|
24
|
+
Returns:
|
25
|
+
A message indicating that the email was sent successfully
|
26
|
+
"""
|
27
|
+
credentials = self.integration.get_credentials()
|
28
|
+
if not credentials:
|
29
|
+
raise ValueError("No credentials found")
|
30
|
+
from_email = credentials.get("from_email", "Manoj <manoj@agentr.dev>")
|
31
|
+
url = "https://api.resend.com/emails"
|
32
|
+
body = {
|
33
|
+
"from": from_email,
|
34
|
+
"to": [to],
|
35
|
+
"subject": subject,
|
36
|
+
"text": content
|
37
|
+
}
|
38
|
+
self._post(url, body)
|
39
|
+
return "Sent Successfully"
|
40
|
+
|
41
|
+
def list_tools(self):
|
42
|
+
return [self.send_email]
|
43
|
+
|
@@ -0,0 +1,57 @@
|
|
1
|
+
from universal_mcp.applications.application import APIApplication
|
2
|
+
from universal_mcp.integrations import Integration
|
3
|
+
|
4
|
+
class TavilyApp(APIApplication):
|
5
|
+
def __init__(self, integration: Integration) -> None:
|
6
|
+
name = "tavily"
|
7
|
+
self.base_url = "https://api.tavily.com"
|
8
|
+
super().__init__(name=name, integration=integration)
|
9
|
+
|
10
|
+
def _get_headers(self):
|
11
|
+
credentials = self.integration.get_credentials()
|
12
|
+
if not credentials:
|
13
|
+
raise ValueError("No credentials found")
|
14
|
+
return {
|
15
|
+
"Authorization": f"Bearer {credentials['api_key']}",
|
16
|
+
"Content-Type": "application/json"
|
17
|
+
}
|
18
|
+
|
19
|
+
def search(self, query: str) -> str:
|
20
|
+
"""Search the web using Tavily's search API
|
21
|
+
|
22
|
+
Args:
|
23
|
+
query: The search query
|
24
|
+
|
25
|
+
Returns:
|
26
|
+
str: A summary of search results
|
27
|
+
"""
|
28
|
+
self.validate()
|
29
|
+
url = f"{self.base_url}/search"
|
30
|
+
payload = {
|
31
|
+
"query": query,
|
32
|
+
"topic": "general",
|
33
|
+
"search_depth": "basic",
|
34
|
+
"max_results": 3,
|
35
|
+
"include_answer": True,
|
36
|
+
"include_raw_content": False,
|
37
|
+
"include_images": False,
|
38
|
+
"include_image_descriptions": False,
|
39
|
+
"include_domains": [],
|
40
|
+
"exclude_domains": []
|
41
|
+
}
|
42
|
+
|
43
|
+
response = self._post(url, payload)
|
44
|
+
result = response.json()
|
45
|
+
|
46
|
+
if "answer" in result:
|
47
|
+
return result["answer"]
|
48
|
+
|
49
|
+
# Fallback to combining top results if no direct answer
|
50
|
+
summaries = []
|
51
|
+
for item in result.get("results", [])[:3]:
|
52
|
+
summaries.append(f"• {item['title']}: {item['snippet']}")
|
53
|
+
|
54
|
+
return "\n".join(summaries)
|
55
|
+
|
56
|
+
def list_tools(self):
|
57
|
+
return [self.search]
|
@@ -0,0 +1,21 @@
|
|
1
|
+
from universal_mcp.applications.application import APIApplication
|
2
|
+
|
3
|
+
|
4
|
+
class ZenQuoteApp(APIApplication):
|
5
|
+
def __init__(self, **kwargs) -> None:
|
6
|
+
super().__init__(name="zenquote", **kwargs)
|
7
|
+
|
8
|
+
def get_quote(self) -> str:
|
9
|
+
"""Get an inspirational quote from the Zen Quotes API
|
10
|
+
|
11
|
+
Returns:
|
12
|
+
A random inspirational quote
|
13
|
+
"""
|
14
|
+
url = "https://zenquotes.io/api/random"
|
15
|
+
response = self._get(url)
|
16
|
+
data = response.json()
|
17
|
+
quote_data = data[0]
|
18
|
+
return f"{quote_data['q']} - {quote_data['a']}"
|
19
|
+
|
20
|
+
def list_tools(self):
|
21
|
+
return [self.get_quote]
|
universal_mcp/cli.py
ADDED
@@ -0,0 +1,111 @@
|
|
1
|
+
import typer
|
2
|
+
from pathlib import Path
|
3
|
+
import sys
|
4
|
+
|
5
|
+
app = typer.Typer()
|
6
|
+
|
7
|
+
@app.command()
|
8
|
+
def generate(schema_path: Path = typer.Option(..., "--schema", "-s")):
|
9
|
+
"""Generate API client from OpenAPI schema"""
|
10
|
+
if not schema_path.exists():
|
11
|
+
typer.echo(f"Error: Schema file {schema_path} does not exist", err=True)
|
12
|
+
raise typer.Exit(1)
|
13
|
+
from .utils.openapi import generate_api_client, load_schema
|
14
|
+
|
15
|
+
try:
|
16
|
+
schema = load_schema(schema_path)
|
17
|
+
except Exception as e:
|
18
|
+
typer.echo(f"Error loading schema: {e}", err=True)
|
19
|
+
raise typer.Exit(1)
|
20
|
+
code = generate_api_client(schema)
|
21
|
+
print(code)
|
22
|
+
|
23
|
+
@app.command()
|
24
|
+
def run():
|
25
|
+
"""Run the MCP server"""
|
26
|
+
from universal_mcp.servers.server import AgentRServer
|
27
|
+
mcp = AgentRServer(name="AgentR Server", description="AgentR Server")
|
28
|
+
mcp.run()
|
29
|
+
|
30
|
+
@app.command()
|
31
|
+
def install(app_name: str = typer.Argument(..., help="Name of app to install")):
|
32
|
+
"""Install an app"""
|
33
|
+
# List of supported apps
|
34
|
+
supported_apps = ["claude", "cursor"]
|
35
|
+
|
36
|
+
if app_name not in supported_apps:
|
37
|
+
typer.echo("Available apps:")
|
38
|
+
for app in supported_apps:
|
39
|
+
typer.echo(f" - {app}")
|
40
|
+
typer.echo(f"\nApp '{app_name}' not supported")
|
41
|
+
raise typer.Exit(1)
|
42
|
+
|
43
|
+
import json
|
44
|
+
|
45
|
+
# Print instructions before asking for API key
|
46
|
+
typer.echo("╭─ Instruction ─────────────────────────────────────────────────────────────────╮")
|
47
|
+
typer.echo("│ API key is required. Visit https://agentr.dev to create an API key. │")
|
48
|
+
typer.echo("╰───────────────────────────────────────────────────────────────────────────────╯")
|
49
|
+
|
50
|
+
# Prompt for API key
|
51
|
+
api_key = typer.prompt("Enter your AgentR API key", hide_input=True)
|
52
|
+
|
53
|
+
if app_name == "claude":
|
54
|
+
typer.echo(f"Installing mcp server for: {app_name}")
|
55
|
+
|
56
|
+
# Determine platform-specific config path
|
57
|
+
if sys.platform == "darwin": # macOS
|
58
|
+
config_path = Path.home() / "Library/Application Support/Claude/claude_desktop_config.json"
|
59
|
+
elif sys.platform == "win32": # Windows
|
60
|
+
config_path = Path.home() / "AppData/Roaming/Claude/claude_desktop_config.json"
|
61
|
+
else:
|
62
|
+
typer.echo("Unsupported platform. Only macOS and Windows are currently supported.", err=True)
|
63
|
+
raise typer.Exit(1)
|
64
|
+
|
65
|
+
|
66
|
+
with open(config_path, 'r') as f:
|
67
|
+
config = json.load(f)
|
68
|
+
if 'mcpServers' not in config:
|
69
|
+
config['mcpServers'] = {}
|
70
|
+
config['mcpServers']['universal_mcp'] = {
|
71
|
+
"command": "uvx",
|
72
|
+
"args": ["universal_mcp@latest", "run"],
|
73
|
+
"env": {
|
74
|
+
"AGENTR_API_KEY": api_key
|
75
|
+
}
|
76
|
+
}
|
77
|
+
with open(config_path, 'w') as f:
|
78
|
+
json.dump(config, f, indent=4)
|
79
|
+
typer.echo("App installed successfully")
|
80
|
+
elif app_name == "cursor":
|
81
|
+
typer.echo(f"Installing mcp server for: {app_name}")
|
82
|
+
|
83
|
+
# Set up Cursor config path
|
84
|
+
config_path = Path.home() / ".cursor/mcp.json"
|
85
|
+
|
86
|
+
# Create config directory if it doesn't exist
|
87
|
+
config_path.parent.mkdir(parents=True, exist_ok=True)
|
88
|
+
|
89
|
+
# Create or load existing config
|
90
|
+
if config_path.exists():
|
91
|
+
with open(config_path, 'r') as f:
|
92
|
+
config = json.load(f)
|
93
|
+
else:
|
94
|
+
config = {}
|
95
|
+
|
96
|
+
if 'mcpServers' not in config:
|
97
|
+
config['mcpServers'] = {}
|
98
|
+
config['mcpServers']['universal_mcp'] = {
|
99
|
+
"command": "uvx",
|
100
|
+
"args": ["universal_mcp@latest", "run"],
|
101
|
+
"env": {
|
102
|
+
"AGENTR_API_KEY": api_key
|
103
|
+
}
|
104
|
+
}
|
105
|
+
|
106
|
+
with open(config_path, 'w') as f:
|
107
|
+
json.dump(config, f, indent=4)
|
108
|
+
typer.echo("App installed successfully")
|
109
|
+
|
110
|
+
if __name__ == "__main__":
|
111
|
+
app()
|
universal_mcp/config.py
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
from pydantic import BaseModel
|
2
|
+
from typing import Literal
|
3
|
+
|
4
|
+
class StoreConfig(BaseModel):
|
5
|
+
type: Literal["memory", "environment"]
|
6
|
+
|
7
|
+
class IntegrationConfig(BaseModel):
|
8
|
+
name: str
|
9
|
+
type: Literal["api_key", "agentr"]
|
10
|
+
credentials: dict | None = None
|
11
|
+
store: StoreConfig | None = None
|
12
|
+
|
13
|
+
class AppConfig(BaseModel):
|
14
|
+
name: str
|
15
|
+
integration: IntegrationConfig | None = None
|
@@ -0,0 +1,25 @@
|
|
1
|
+
# Integrations
|
2
|
+
|
3
|
+
This package provides integration classes for handling authentication and authorization with external services.
|
4
|
+
|
5
|
+
## Overview
|
6
|
+
|
7
|
+
An Integration defines how an application authenticates and authorizes with a service provider. The base `Integration` class provides an interface that all integrations must implement.
|
8
|
+
|
9
|
+
## Supported Integrations
|
10
|
+
|
11
|
+
### AgentR Integration
|
12
|
+
The `AgentRIntegration` class handles OAuth-based authentication flow with the AgentR API. It requires an API key which can be obtained from [agentr.dev](https://agentr.dev).
|
13
|
+
|
14
|
+
### API Key Integration
|
15
|
+
The `ApiKeyIntegration` class provides a simple API key based authentication mechanism. API keys are configured via environment variables.
|
16
|
+
|
17
|
+
## Usage
|
18
|
+
|
19
|
+
Each integration implements three key methods:
|
20
|
+
|
21
|
+
- `authorize()` - Initiates the authorization flow
|
22
|
+
- `get_credentials()` - Retrieves stored credentials
|
23
|
+
- `set_credentials()` - Stores new credentials
|
24
|
+
|
25
|
+
See the individual integration classes for specific usage details.
|