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.
@@ -0,0 +1,2 @@
1
+ def hello() -> str:
2
+ return "Hello from universal-mcp!"
@@ -0,0 +1,31 @@
1
+ from universal_mcp.applications.zenquotes.app import ZenQuoteApp
2
+ from universal_mcp.applications.tavily.app import TavilyApp
3
+ from universal_mcp.applications.github.app import GithubApp
4
+ from universal_mcp.applications.google_calendar.app import GoogleCalendarApp
5
+ from universal_mcp.applications.google_mail.app import GmailApp
6
+ from universal_mcp.applications.resend.app import ResendApp
7
+ from universal_mcp.applications.reddit.app import RedditApp
8
+ from universal_mcp.applications.application import Application, APIApplication
9
+
10
+ def app_from_name(name: str):
11
+ name = name.lower().strip()
12
+ name = name.replace(" ", "-")
13
+ if name == "zenquotes":
14
+ return ZenQuoteApp
15
+ elif name == "tavily":
16
+ return TavilyApp
17
+ elif name == "github":
18
+ return GithubApp
19
+ elif name == "google-calendar":
20
+ return GoogleCalendarApp
21
+ elif name == "google-mail":
22
+ return GmailApp
23
+ elif name == "resend":
24
+ return ResendApp
25
+ elif name == "reddit":
26
+ return RedditApp
27
+ else:
28
+ raise ValueError(f"App {name} not found")
29
+
30
+
31
+ __all__ = ["app_from_name", "Application", "APIApplication"]
File without changes
@@ -0,0 +1,101 @@
1
+ from abc import ABC
2
+ from loguru import logger
3
+ from universal_mcp.exceptions import NotAuthorizedError
4
+ from universal_mcp.integrations import Integration
5
+ import httpx
6
+
7
+ class Application(ABC):
8
+ """
9
+ Application is collection of tools that can be used by an agent.
10
+ """
11
+ def __init__(self, name: str, **kwargs):
12
+ self.name = name
13
+ self.tools = []
14
+
15
+ def list_tools(self):
16
+ return self.tools
17
+
18
+ class APIApplication(Application):
19
+ """
20
+ APIApplication is an application that uses an API to interact with the world.
21
+ """
22
+ def __init__(self, name: str, integration: Integration = None, **kwargs):
23
+ super().__init__(name, **kwargs)
24
+ self.integration = integration
25
+
26
+ def _get_headers(self):
27
+ return {}
28
+
29
+ def _get(self, url, params=None):
30
+ try:
31
+ headers = self._get_headers()
32
+ response = httpx.get(url, headers=headers, params=params)
33
+ response.raise_for_status()
34
+ return response
35
+ except NotAuthorizedError as e:
36
+ logger.warning(f"Authorization needed: {e.message}")
37
+ raise e
38
+ except Exception as e:
39
+ logger.error(f"Error getting {url}: {e}")
40
+ raise e
41
+
42
+
43
+ def _post(self, url, data, params=None):
44
+ try:
45
+ headers = self._get_headers()
46
+ response = httpx.post(url, headers=headers, json=data, params=params)
47
+ response.raise_for_status()
48
+ return response
49
+ except NotAuthorizedError as e:
50
+ logger.warning(f"Authorization needed: {e.message}")
51
+ raise e
52
+ except httpx.HTTPStatusError as e:
53
+ if e.response.status_code == 429:
54
+ return e.response.text or "Rate limit exceeded. Please try again later."
55
+ else:
56
+ raise e
57
+ except Exception as e:
58
+ logger.error(f"Error posting {url}: {e}")
59
+ raise e
60
+
61
+ def _put(self, url, data, params=None):
62
+ try:
63
+ headers = self._get_headers()
64
+ response = httpx.put(url, headers=headers, json=data, params=params)
65
+ response.raise_for_status()
66
+ return response
67
+ except NotAuthorizedError as e:
68
+ logger.warning(f"Authorization needed: {e.message}")
69
+ raise e
70
+ except Exception as e:
71
+ logger.error(f"Error posting {url}: {e}")
72
+ raise e
73
+
74
+ def _delete(self, url, params=None):
75
+ try:
76
+ headers = self._get_headers()
77
+ response = httpx.delete(url, headers=headers, params=params)
78
+ response.raise_for_status()
79
+ return response
80
+ except NotAuthorizedError as e:
81
+ logger.warning(f"Authorization needed: {e.message}")
82
+ raise e
83
+ except Exception as e:
84
+ logger.error(f"Error posting {url}: {e}")
85
+ raise e
86
+
87
+ def _patch(self, url, data, params=None):
88
+ try:
89
+ headers = self._get_headers()
90
+ response = httpx.patch(url, headers=headers, json=data, params=params)
91
+ response.raise_for_status()
92
+ return response
93
+ except NotAuthorizedError as e:
94
+ logger.warning(f"Authorization needed: {e.message}")
95
+ raise e
96
+ except Exception as e:
97
+ logger.error(f"Error patching {url}: {e}")
98
+ raise e
99
+
100
+ def validate(self):
101
+ pass
@@ -0,0 +1,354 @@
1
+ from universal_mcp.integrations import Integration
2
+ from universal_mcp.applications.application import APIApplication
3
+ from loguru import logger
4
+ from typing import List, Dict, Any
5
+
6
+ class GithubApp(APIApplication):
7
+ def __init__(self, integration: Integration) -> None:
8
+ super().__init__(name="github", integration=integration)
9
+ self.base_api_url = "https://api.github.com/repos"
10
+
11
+ def _get_headers(self):
12
+ if not self.integration:
13
+ raise ValueError("Integration not configured")
14
+ credentials = self.integration.get_credentials()
15
+ if "headers" in credentials:
16
+ return credentials["headers"]
17
+ return {
18
+ "Authorization": f"Bearer {credentials['access_token']}",
19
+ "Accept": "application/vnd.github.v3+json"
20
+ }
21
+
22
+ def star_repository(self, repo_full_name: str) -> str:
23
+ """Star a GitHub repository
24
+
25
+ Args:
26
+ repo_full_name: The full name of the repository (e.g. 'owner/repo')
27
+
28
+ Returns:
29
+
30
+ A confirmation message
31
+ """
32
+ url = f"https://api.github.com/user/starred/{repo_full_name}"
33
+ response = self._put(url, data={})
34
+
35
+ if response.status_code == 204:
36
+ return f"Successfully starred repository {repo_full_name}"
37
+ elif response.status_code == 404:
38
+ return f"Repository {repo_full_name} not found"
39
+ else:
40
+ logger.error(response.text)
41
+ return f"Error starring repository: {response.text}"
42
+
43
+
44
+ def list_commits(self, repo_full_name: str) -> str:
45
+ """List recent commits for a GitHub repository
46
+
47
+ Args:
48
+ repo_full_name: The full name of the repository (e.g. 'owner/repo')
49
+
50
+ Returns:
51
+ A formatted list of recent commits
52
+ """
53
+ repo_full_name = repo_full_name.strip()
54
+ url = f"{self.base_api_url}/{repo_full_name}/commits"
55
+ response = self._get(url)
56
+ response.raise_for_status()
57
+
58
+ commits = response.json()
59
+ if not commits:
60
+ return f"No commits found for repository {repo_full_name}"
61
+
62
+ result = f"Recent commits for {repo_full_name}:\n\n"
63
+ for commit in commits[:12]: # Limit to 12 commits
64
+ sha = commit.get("sha", "")[:7]
65
+ message = commit.get("commit", {}).get("message", "").split('\n')[0]
66
+ author = commit.get("commit", {}).get("author", {}).get("name", "Unknown")
67
+
68
+ result += f"- {sha}: {message} (by {author})\n"
69
+
70
+ return result
71
+
72
+ def list_branches(self, repo_full_name: str) -> str:
73
+ """List branches for a GitHub repository
74
+
75
+ Args:
76
+ repo_full_name: The full name of the repository (e.g. 'owner/repo')
77
+
78
+ Returns:
79
+ A formatted list of branches
80
+ """
81
+ repo_full_name = repo_full_name.strip()
82
+ url = f"{self.base_api_url}/{repo_full_name}/branches"
83
+ response = self._get(url)
84
+ response.raise_for_status()
85
+
86
+ branches = response.json()
87
+ if not branches:
88
+ return f"No branches found for repository {repo_full_name}"
89
+
90
+ result = f"Branches for {repo_full_name}:\n\n"
91
+ for branch in branches:
92
+ branch_name = branch.get("name", "Unknown")
93
+ result += f"- {branch_name}\n"
94
+
95
+ return result
96
+
97
+ def list_pull_requests(self, repo_full_name: str, state: str = "open") -> str:
98
+ """List pull requests for a GitHub repository
99
+
100
+ Args:
101
+ repo_full_name: The full name of the repository (e.g. 'owner/repo')
102
+ state: The state of the pull requests to filter by (open, closed, or all)
103
+
104
+ Returns:
105
+ A formatted list of pull requests
106
+ """
107
+ repo_full_name = repo_full_name.strip()
108
+ url = f"{self.base_api_url}/{repo_full_name}/pulls"
109
+ params = {"state": state}
110
+ response = self._get(url, params=params)
111
+ response.raise_for_status()
112
+
113
+ pull_requests = response.json()
114
+ if not pull_requests:
115
+ return f"No pull requests found for repository {repo_full_name} with state '{state}'"
116
+
117
+ result = f"Pull requests for {repo_full_name} (State: {state}):\n\n"
118
+ for pr in pull_requests:
119
+ pr_title = pr.get("title", "No Title")
120
+ pr_number = pr.get("number", "Unknown")
121
+ pr_state = pr.get("state", "Unknown")
122
+ pr_user = pr.get("user", {}).get("login", "Unknown")
123
+
124
+ result += f"- PR #{pr_number}: {pr_title} (by {pr_user}, Status: {pr_state})\n"
125
+
126
+ return result
127
+
128
+ def list_issues(self, repo_full_name: str, state: str = "open", assignee: str = None, labels: str = None, per_page: int = 30, page: int = 1) -> List[Dict[str, Any]]:
129
+ """List issues for a GitHub repository
130
+
131
+ Args:
132
+ repo_full_name: The full name of the repository (e.g. 'owner/repo')
133
+ state: State of issues to return (open, closed, all). Default: open
134
+ assignee: Filter by assignee. Use 'none' for no assignee, '*' for any assignee
135
+ labels: Comma-separated list of label names (e.g. "bug,ui,@high")
136
+ per_page: The number of results per page (max 100)
137
+ page: The page number of the results to fetch
138
+
139
+ Returns:
140
+ The complete GitHub API response
141
+ """
142
+ repo_full_name = repo_full_name.strip()
143
+ url = f"{self.base_api_url}/{repo_full_name}/issues"
144
+
145
+ params = {
146
+ "state": state,
147
+ "per_page": per_page,
148
+ "page": page
149
+ }
150
+
151
+ if assignee:
152
+ params["assignee"] = assignee
153
+ if labels:
154
+ params["labels"] = labels
155
+
156
+ response = self._get(url, params=params)
157
+ response.raise_for_status()
158
+ return response.json()
159
+
160
+ def get_pull_request(self, repo_full_name: str, pull_number: int) -> str:
161
+ """Get a specific pull request for a GitHub repository
162
+
163
+ Args:
164
+ repo_full_name: The full name of the repository (e.g. 'owner/repo')
165
+ pull_number: The number of the pull request to retrieve
166
+
167
+ Returns:
168
+ A formatted string with pull request details
169
+ """
170
+ repo_full_name = repo_full_name.strip()
171
+ url = f"{self.base_api_url}/{repo_full_name}/pulls/{pull_number}"
172
+ response = self._get(url)
173
+ response.raise_for_status()
174
+
175
+ pr = response.json()
176
+ pr_title = pr.get("title", "No Title")
177
+ pr_number = pr.get("number", "Unknown")
178
+ pr_state = pr.get("state", "Unknown")
179
+ pr_user = pr.get("user", {}).get("login", "Unknown")
180
+ pr_body = pr.get("body", "No description provided.")
181
+
182
+ result = (
183
+ f"Pull Request #{pr_number}: {pr_title}\n"
184
+ f"Created by: {pr_user}\n"
185
+ f"Status: {pr_state}\n"
186
+ f"Description: {pr_body}\n"
187
+ )
188
+
189
+ return result
190
+
191
+ def create_pull_request(self, repo_full_name: str, head: str, base: str, title: str = None,
192
+ body: str = None, issue: int = None, maintainer_can_modify: bool = True,
193
+ draft: bool = False) -> Dict[str, Any]:
194
+ """Create a new pull request for a GitHub repository
195
+
196
+ Args:
197
+ repo_full_name: The full name of the repository (e.g. 'owner/repo')
198
+ head: The name of the branch where your changes are implemented
199
+ base: The name of the branch you want the changes pulled into
200
+ title: The title of the new pull request (required if issue is not specified)
201
+ body: The contents of the pull request
202
+ issue: An issue number to convert to a pull request. If specified, the issue's
203
+ title, body, and comments will be used for the pull request
204
+ maintainer_can_modify: Indicates whether maintainers can modify the pull request
205
+ draft: Indicates whether the pull request is a draft
206
+
207
+ Returns:
208
+ The complete GitHub API response
209
+ """
210
+ repo_full_name = repo_full_name.strip()
211
+ url = f"{self.base_api_url}/{repo_full_name}/pulls"
212
+
213
+ pull_request_data = {
214
+ "head": head,
215
+ "base": base,
216
+ "maintainer_can_modify": maintainer_can_modify,
217
+ "draft": draft
218
+ }
219
+
220
+ if issue is not None:
221
+ pull_request_data["issue"] = issue
222
+ else:
223
+ if title is None:
224
+ raise ValueError("Either 'title' or 'issue' must be specified")
225
+ pull_request_data["title"] = title
226
+ if body is not None:
227
+ pull_request_data["body"] = body
228
+
229
+ response = self._post(url, pull_request_data)
230
+ response.raise_for_status()
231
+ return response.json()
232
+
233
+ def create_issue(self, repo_full_name: str, title: str, body: str = "", labels = None) -> str:
234
+ """Create a new issue in a GitHub repository
235
+
236
+ Args:
237
+ repo_full_name: The full name of the repository (e.g. 'owner/repo')
238
+ title: The title of the issue
239
+ body: The contents of the issue
240
+ labels: Labels to associate with this issue. Enter as a comma-separated string
241
+ (e.g. "bug,enhancement,documentation").
242
+ NOTE: Only users with push access can set labels for new issues.
243
+ Labels are silently dropped otherwise.
244
+
245
+ Returns:
246
+ A confirmation message with the new issue details
247
+ """
248
+ repo_full_name = repo_full_name.strip()
249
+ url = f"{self.base_api_url}/{repo_full_name}/issues"
250
+
251
+ issue_data = {
252
+ "title": title,
253
+ "body": body
254
+ }
255
+
256
+ if labels:
257
+ if isinstance(labels, str):
258
+ labels_list = [label.strip() for label in labels.split(",") if label.strip()]
259
+ issue_data["labels"] = labels_list
260
+ else:
261
+ issue_data["labels"] = labels
262
+
263
+ response = self._post(url, issue_data)
264
+ response.raise_for_status()
265
+
266
+ issue = response.json()
267
+ issue_number = issue.get("number", "Unknown")
268
+ issue_url = issue.get("html_url", "")
269
+
270
+ return f"Successfully created issue #{issue_number}:\n" \
271
+ f"Title: {title}\n" \
272
+ f"URL: {issue_url}"
273
+
274
+ def list_repo_activities(self, repo_full_name: str, direction: str = "desc", per_page: int = 30) -> str:
275
+ """List activities for a GitHub repository
276
+
277
+ Args:
278
+ repo_full_name: The full name of the repository (e.g. 'owner/repo')
279
+ direction: The direction to sort the results by (asc or desc). Default: desc
280
+ per_page: The number of results per page (max 100). Default: 30
281
+
282
+ Returns:
283
+ A formatted list of repository activities
284
+ """
285
+ repo_full_name = repo_full_name.strip()
286
+ url = f"{self.base_api_url}/{repo_full_name}/activity"
287
+
288
+ # Build query parameters
289
+ params = {
290
+ "direction": direction,
291
+ "per_page": per_page
292
+ }
293
+
294
+ response = self._get(url, params=params)
295
+ response.raise_for_status()
296
+
297
+ activities = response.json()
298
+ if not activities:
299
+ return f"No activities found for repository {repo_full_name}"
300
+
301
+ result = f"Repository activities for {repo_full_name}:\n\n"
302
+
303
+ for activity in activities:
304
+ # Extract common fields
305
+ timestamp = activity.get("timestamp", "Unknown time")
306
+ actor_name = "Unknown user"
307
+ if "actor" in activity and activity["actor"]:
308
+ actor_name = activity["actor"].get("login", "Unknown user")
309
+
310
+ # Create a simple description of the activity
311
+ result += f"- {actor_name} performed an activity at {timestamp}\n"
312
+
313
+ return result
314
+
315
+ def update_issue(self, repo_full_name: str, issue_number: int, title: str = None,
316
+ body: str = None, assignee: str = None, state: str = None,
317
+ state_reason: str = None) -> Dict[str, Any]:
318
+ """Update an issue in a GitHub repository
319
+
320
+ Args:
321
+ repo_full_name: The full name of the repository (e.g. 'owner/repo')
322
+ issue_number: The number that identifies the issue
323
+ title: The title of the issue
324
+ body: The contents of the issue
325
+ assignee: Username to assign to this issue
326
+ state: State of the issue (open or closed)
327
+ state_reason: Reason for state change (completed, not_planned, reopened, null)
328
+
329
+ Returns:
330
+ The complete GitHub API response
331
+ """
332
+ url = f"{self.base_api_url}/{repo_full_name}/issues/{issue_number}"
333
+
334
+ update_data = {}
335
+ if title is not None:
336
+ update_data["title"] = title
337
+ if body is not None:
338
+ update_data["body"] = body
339
+ if assignee is not None:
340
+ update_data["assignee"] = assignee
341
+ if state is not None:
342
+ update_data["state"] = state
343
+ if state_reason is not None:
344
+ update_data["state_reason"] = state_reason
345
+
346
+ response = self._patch(url, update_data)
347
+ response.raise_for_status()
348
+ return response.json()
349
+
350
+ def list_tools(self):
351
+ return [self.star_repository, self.list_commits, self.list_branches,
352
+ self.list_pull_requests, self.list_issues, self.get_pull_request,
353
+ self.create_pull_request, self.create_issue, self.update_issue,
354
+ self.list_repo_activities]