agentr 0.1.7__tar.gz → 0.1.9__tar.gz

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