universal-mcp 0.1.1__py3-none-any.whl → 0.1.1rc1__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.
Files changed (34) hide show
  1. universal_mcp/applications/__init__.py +23 -28
  2. universal_mcp/applications/application.py +13 -8
  3. universal_mcp/applications/e2b/app.py +74 -0
  4. universal_mcp/applications/firecrawl/app.py +381 -0
  5. universal_mcp/applications/github/README.md +35 -0
  6. universal_mcp/applications/github/app.py +133 -100
  7. universal_mcp/applications/google_calendar/app.py +170 -139
  8. universal_mcp/applications/google_mail/app.py +185 -160
  9. universal_mcp/applications/markitdown/app.py +32 -0
  10. universal_mcp/applications/reddit/app.py +112 -71
  11. universal_mcp/applications/resend/app.py +3 -8
  12. universal_mcp/applications/serp/app.py +84 -0
  13. universal_mcp/applications/tavily/app.py +11 -10
  14. universal_mcp/applications/zenquotes/app.py +3 -3
  15. universal_mcp/cli.py +98 -16
  16. universal_mcp/config.py +20 -3
  17. universal_mcp/exceptions.py +1 -3
  18. universal_mcp/integrations/__init__.py +6 -2
  19. universal_mcp/integrations/agentr.py +26 -24
  20. universal_mcp/integrations/integration.py +72 -35
  21. universal_mcp/servers/__init__.py +21 -1
  22. universal_mcp/servers/server.py +77 -44
  23. universal_mcp/stores/__init__.py +15 -2
  24. universal_mcp/stores/store.py +123 -13
  25. universal_mcp/utils/__init__.py +1 -0
  26. universal_mcp/utils/api_generator.py +269 -0
  27. universal_mcp/utils/docgen.py +360 -0
  28. universal_mcp/utils/installation.py +17 -2
  29. universal_mcp/utils/openapi.py +202 -104
  30. {universal_mcp-0.1.1.dist-info → universal_mcp-0.1.1rc1.dist-info}/METADATA +22 -5
  31. universal_mcp-0.1.1rc1.dist-info/RECORD +37 -0
  32. universal_mcp-0.1.1.dist-info/RECORD +0 -29
  33. {universal_mcp-0.1.1.dist-info → universal_mcp-0.1.1rc1.dist-info}/WHEEL +0 -0
  34. {universal_mcp-0.1.1.dist-info → universal_mcp-0.1.1rc1.dist-info}/entry_points.txt +0 -0
@@ -0,0 +1,32 @@
1
+ from markitdown import MarkItDown
2
+
3
+ from universal_mcp.applications.application import Application
4
+
5
+
6
+ class MarkitdownApp(Application):
7
+ def __init__(self, **kwargs):
8
+ super().__init__(name="markitdown", **kwargs)
9
+ self.markitdown = MarkItDown()
10
+
11
+ async def convert_to_markdown(self, uri: str) -> str:
12
+ """Convert a web page, file, or data URI to markdown format.
13
+
14
+ Args:
15
+ uri (str): The URI to convert. Supported URI schemes:
16
+ - http:// or https:// for web pages
17
+ - file:// for local files
18
+ - data: for data URIs
19
+
20
+ Returns:
21
+ str: The markdown representation of the resource content.
22
+
23
+ Example:
24
+ >>> await convert_to_markdown("https://example.com")
25
+ "# Example Domain\n\nThis domain is for use in illustrative examples..."
26
+ """
27
+ return self.markitdown.convert_uri(uri).markdown
28
+
29
+ def list_tools(self):
30
+ return [
31
+ self.convert_to_markdown,
32
+ ]
@@ -1,8 +1,10 @@
1
1
  import httpx
2
+ from loguru import logger
3
+
2
4
  from universal_mcp.applications.application import APIApplication
3
- from universal_mcp.integrations import Integration
4
5
  from universal_mcp.exceptions import NotAuthorizedError
5
- from loguru import logger
6
+ from universal_mcp.integrations import Integration
7
+
6
8
 
7
9
  class RedditApp(APIApplication):
8
10
  def __init__(self, integration: Integration) -> None:
@@ -32,126 +34,146 @@ class RedditApp(APIApplication):
32
34
  raise ValueError("Integration not configured for RedditApp")
33
35
  credentials = self.integration.get_credentials()
34
36
  if "access_token" not in credentials:
35
- logger.error("Reddit credentials found but missing 'access_token'.")
36
- raise ValueError("Invalid Reddit credentials format.")
37
+ logger.error("Reddit credentials found but missing 'access_token'.")
38
+ raise ValueError("Invalid Reddit credentials format.")
37
39
 
38
40
  return {
39
41
  "Authorization": f"Bearer {credentials['access_token']}",
40
- "User-Agent": "agentr-reddit-app/0.1 by AgentR"
42
+ "User-Agent": "agentr-reddit-app/0.1 by AgentR",
41
43
  }
42
-
43
- def get_subreddit_posts(self, subreddit: str, limit: int = 5, timeframe: str = "day") -> str:
44
+
45
+ def get_subreddit_posts(
46
+ self, subreddit: str, limit: int = 5, timeframe: str = "day"
47
+ ) -> str:
44
48
  """Get the top posts from a specified subreddit over a given timeframe.
45
-
49
+
46
50
  Args:
47
51
  subreddit: The name of the subreddit (e.g., 'python', 'worldnews') without the 'r/'.
48
52
  limit: The maximum number of posts to return (default: 5, max: 100).
49
53
  timeframe: The time period for top posts. Valid options: 'hour', 'day', 'week', 'month', 'year', 'all' (default: 'day').
50
-
54
+
51
55
  Returns:
52
56
  A formatted string listing the top posts or an error message.
53
57
  """
54
- valid_timeframes = ['hour', 'day', 'week', 'month', 'year', 'all']
58
+ valid_timeframes = ["hour", "day", "week", "month", "year", "all"]
55
59
  if timeframe not in valid_timeframes:
56
60
  return f"Error: Invalid timeframe '{timeframe}'. Please use one of: {', '.join(valid_timeframes)}"
57
-
61
+
58
62
  if not 1 <= limit <= 100:
59
- return f"Error: Invalid limit '{limit}'. Please use a value between 1 and 100."
63
+ return (
64
+ f"Error: Invalid limit '{limit}'. Please use a value between 1 and 100."
65
+ )
60
66
 
61
-
62
67
  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}'")
68
+ params = {"limit": limit, "t": timeframe}
69
+
70
+ logger.info(
71
+ f"Requesting top {limit} posts from r/{subreddit} for timeframe '{timeframe}'"
72
+ )
69
73
  response = self._get(url, params=params)
70
74
 
71
75
  data = response.json()
72
-
76
+
73
77
  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', '')}"
78
+ logger.error(
79
+ f"Reddit API error: {data['error']} - {data.get('message', '')}"
80
+ )
81
+ return f"Error from Reddit API: {data['error']} - {data.get('message', '')}"
76
82
 
77
83
  posts = data.get("data", {}).get("children", [])
78
-
84
+
79
85
  if not posts:
80
- return f"No top posts found in r/{subreddit} for the timeframe '{timeframe}'."
86
+ return (
87
+ f"No top posts found in r/{subreddit} for the timeframe '{timeframe}'."
88
+ )
81
89
 
82
- result_lines = [f"Top {len(posts)} posts from r/{subreddit} (timeframe: {timeframe}):\n"]
90
+ result_lines = [
91
+ f"Top {len(posts)} posts from r/{subreddit} (timeframe: {timeframe}):\n"
92
+ ]
83
93
  for i, post_container in enumerate(posts):
84
94
  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', '')
95
+ title = post.get("title", "No Title")
96
+ score = post.get("score", 0)
97
+ author = post.get("author", "Unknown Author")
98
+ permalink = post.get("permalink", "")
89
99
  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})")
100
+
101
+ result_lines.append(f'{i + 1}. "{title}" by u/{author} (Score: {score})')
92
102
  result_lines.append(f" Link: {full_url}")
93
103
 
94
104
  return "\n".join(result_lines)
95
105
 
96
-
97
- def search_subreddits(self, query: str, limit: int = 5, sort: str = "relevance") -> str:
106
+ def search_subreddits(
107
+ self, query: str, limit: int = 5, sort: str = "relevance"
108
+ ) -> str:
98
109
  """Search for subreddits matching a query string.
99
-
110
+
100
111
  Args:
101
112
  query: The text to search for in subreddit names and descriptions.
102
113
  limit: The maximum number of subreddits to return (default: 5, max: 100).
103
114
  sort: The order of results. Valid options: 'relevance', 'activity' (default: 'relevance').
104
-
115
+
105
116
  Returns:
106
117
  A formatted string listing the found subreddits and their descriptions, or an error message.
107
118
  """
108
- valid_sorts = ['relevance', 'activity']
119
+ valid_sorts = ["relevance", "activity"]
109
120
  if sort not in valid_sorts:
110
121
  return f"Error: Invalid sort option '{sort}'. Please use one of: {', '.join(valid_sorts)}"
111
-
122
+
112
123
  if not 1 <= limit <= 100:
113
- return f"Error: Invalid limit '{limit}'. Please use a value between 1 and 100."
124
+ return (
125
+ f"Error: Invalid limit '{limit}'. Please use a value between 1 and 100."
126
+ )
114
127
 
115
-
116
128
  url = f"{self.base_api_url}/subreddits/search"
117
129
  params = {
118
130
  "q": query,
119
131
  "limit": limit,
120
132
  "sort": sort,
121
133
  # Optionally include NSFW results? Defaulting to false for safety.
122
- # "include_over_18": "false"
134
+ # "include_over_18": "false"
123
135
  }
124
-
125
- logger.info(f"Searching for subreddits matching '{query}' (limit: {limit}, sort: {sort})")
136
+
137
+ logger.info(
138
+ f"Searching for subreddits matching '{query}' (limit: {limit}, sort: {sort})"
139
+ )
126
140
  response = self._get(url, params=params)
127
-
141
+
128
142
  data = response.json()
129
143
 
130
144
  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', '')}"
145
+ logger.error(
146
+ f"Reddit API error during subreddit search: {data['error']} - {data.get('message', '')}"
147
+ )
148
+ return f"Error from Reddit API during search: {data['error']} - {data.get('message', '')}"
133
149
 
134
150
  subreddits = data.get("data", {}).get("children", [])
135
-
151
+
136
152
  if not subreddits:
137
153
  return f"No subreddits found matching the query '{query}'."
138
154
 
139
- result_lines = [f"Found {len(subreddits)} subreddits matching '{query}' (sorted by {sort}):\n"]
155
+ result_lines = [
156
+ f"Found {len(subreddits)} subreddits matching '{query}' (sorted by {sort}):\n"
157
+ ]
140
158
  for i, sub_container in enumerate(subreddits):
141
159
  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)
160
+ display_name = sub_data.get("display_name", "N/A") # e.g., 'python'
161
+ title = sub_data.get(
162
+ "title", "No Title"
163
+ ) # Often the same as display_name or slightly longer
164
+ subscribers = sub_data.get("subscribers", 0)
145
165
  # Use public_description if available, fallback to title
146
- description = sub_data.get('public_description', '').strip() or title
147
-
166
+ description = sub_data.get("public_description", "").strip() or title
167
+
148
168
  # Format subscriber count nicely
149
169
  subscriber_str = f"{subscribers:,}" if subscribers else "Unknown"
150
-
151
- result_lines.append(f"{i+1}. r/{display_name} ({subscriber_str} subscribers)")
170
+
171
+ result_lines.append(
172
+ f"{i + 1}. r/{display_name} ({subscriber_str} subscribers)"
173
+ )
152
174
  if description:
153
175
  result_lines.append(f" Description: {description}")
154
-
176
+
155
177
  return "\n".join(result_lines)
156
178
 
157
179
  def get_post_flairs(self, subreddit: str):
@@ -163,9 +185,9 @@ class RedditApp(APIApplication):
163
185
  Returns:
164
186
  A list of dictionaries containing flair details, or an error message.
165
187
  """
166
-
188
+
167
189
  url = f"{self.base_api_url}/r/{subreddit}/api/link_flair_v2"
168
-
190
+
169
191
  logger.info(f"Fetching post flairs for subreddit: r/{subreddit}")
170
192
  response = self._get(url)
171
193
 
@@ -174,8 +196,16 @@ class RedditApp(APIApplication):
174
196
  return f"No post flairs available for r/{subreddit}."
175
197
 
176
198
  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):
199
+
200
+ def create_post(
201
+ self,
202
+ subreddit: str,
203
+ title: str,
204
+ kind: str = "self",
205
+ text: str = None,
206
+ url: str = None,
207
+ flair_id: str = None,
208
+ ):
179
209
  """Create a new post in a specified subreddit.
180
210
 
181
211
  Args:
@@ -186,7 +216,7 @@ class RedditApp(APIApplication):
186
216
  url: The URL of the link or image; required if kind is 'link'.
187
217
  For image posts to be displayed correctly, the URL must directly point to an image file
188
218
  and end with a valid image extension (e.g., .jpg, .png, or .gif).
189
- Note that .gif support can be inconsistent.
219
+ Note that .gif support can be inconsistent.
190
220
  flair_id: The ID of the flair to assign to the post.
191
221
 
192
222
  Returns:
@@ -218,10 +248,16 @@ class RedditApp(APIApplication):
218
248
  response_json = response.json()
219
249
 
220
250
  # Check for Reddit API errors in the response
221
- if response_json and "json" in response_json and "errors" in response_json["json"]:
251
+ if (
252
+ response_json
253
+ and "json" in response_json
254
+ and "errors" in response_json["json"]
255
+ ):
222
256
  errors = response_json["json"]["errors"]
223
257
  if errors:
224
- error_message = ", ".join([f"{code}: {message}" for code, message in errors])
258
+ error_message = ", ".join(
259
+ [f"{code}: {message}" for code, message in errors]
260
+ )
225
261
  return f"Reddit API error: {error_message}"
226
262
 
227
263
  return response_json
@@ -241,7 +277,7 @@ class RedditApp(APIApplication):
241
277
  url = f"https://oauth.reddit.com/api/info.json?id={comment_id}"
242
278
 
243
279
  # Make the GET request to the Reddit API
244
-
280
+
245
281
  response = self._get(url)
246
282
 
247
283
  data = response.json()
@@ -250,7 +286,7 @@ class RedditApp(APIApplication):
250
286
  return comments[0]["data"]
251
287
  else:
252
288
  return {"error": "Comment not found."}
253
-
289
+
254
290
  def post_comment(self, parent_id: str, text: str) -> dict:
255
291
  """
256
292
  Post a comment to a Reddit post or another comment.
@@ -262,7 +298,7 @@ class RedditApp(APIApplication):
262
298
  Returns:
263
299
  A dictionary containing the response from the Reddit API, or an error message if posting fails.
264
300
  """
265
-
301
+
266
302
  url = f"{self.base_api_url}/api/comment"
267
303
  data = {
268
304
  "parent": parent_id,
@@ -285,7 +321,7 @@ class RedditApp(APIApplication):
285
321
  Returns:
286
322
  A dictionary containing the response from the Reddit API, or an error message if editing fails.
287
323
  """
288
-
324
+
289
325
  url = f"{self.base_api_url}/api/editusertext"
290
326
  data = {
291
327
  "thing_id": content_id,
@@ -297,7 +333,6 @@ class RedditApp(APIApplication):
297
333
 
298
334
  return response.json()
299
335
 
300
-
301
336
  def delete_content(self, content_id: str) -> dict:
302
337
  """
303
338
  Delete a Reddit post or comment.
@@ -308,7 +343,7 @@ class RedditApp(APIApplication):
308
343
  Returns:
309
344
  A dictionary containing the response from the Reddit API, or an error message if deletion fails.
310
345
  """
311
-
346
+
312
347
  url = f"{self.base_api_url}/api/del"
313
348
  data = {
314
349
  "id": content_id,
@@ -324,6 +359,12 @@ class RedditApp(APIApplication):
324
359
 
325
360
  def list_tools(self):
326
361
  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
- ]
362
+ self.get_subreddit_posts,
363
+ self.search_subreddits,
364
+ self.get_post_flairs,
365
+ self.create_post,
366
+ self.get_comment_by_id,
367
+ self.post_comment,
368
+ self.edit_content,
369
+ self.delete_content,
370
+ ]
@@ -1,6 +1,7 @@
1
1
  from universal_mcp.applications.application import APIApplication
2
2
  from universal_mcp.integrations import Integration
3
3
 
4
+
4
5
  class ResendApp(APIApplication):
5
6
  def __init__(self, integration: Integration) -> None:
6
7
  super().__init__(name="resend", integration=integration)
@@ -20,7 +21,7 @@ class ResendApp(APIApplication):
20
21
  to: The email address to send the email to
21
22
  subject: The subject of the email
22
23
  content: The content of the email
23
-
24
+
24
25
  Returns:
25
26
  A message indicating that the email was sent successfully
26
27
  """
@@ -29,15 +30,9 @@ class ResendApp(APIApplication):
29
30
  raise ValueError("No credentials found")
30
31
  from_email = credentials.get("from_email", "Manoj <manoj@agentr.dev>")
31
32
  url = "https://api.resend.com/emails"
32
- body = {
33
- "from": from_email,
34
- "to": [to],
35
- "subject": subject,
36
- "text": content
37
- }
33
+ body = {"from": from_email, "to": [to], "subject": subject, "text": content}
38
34
  self._post(url, body)
39
35
  return "Sent Successfully"
40
36
 
41
37
  def list_tools(self):
42
38
  return [self.send_email]
43
-
@@ -0,0 +1,84 @@
1
+ import httpx
2
+ from loguru import logger
3
+ from serpapi import SerpApiClient as SerpApiSearch
4
+
5
+ from universal_mcp.applications.application import APIApplication
6
+
7
+
8
+ class SerpApp(APIApplication):
9
+ def __init__(self, **kwargs):
10
+ super().__init__(name="serpapi", **kwargs)
11
+ self.api_key: str | None = None
12
+
13
+ def _set_api_key(self):
14
+ if self.api_key is not None:
15
+ return
16
+ if not self.integration:
17
+ raise ValueError("Integration is None. Cannot retrieve SERP API Key.")
18
+
19
+ credentials = self.integration.get_credentials()
20
+ if not credentials:
21
+ raise ValueError(
22
+ f"Failed to retrieve SERP API Key using integration '{self.integration.name}'. "
23
+ f"Check store configuration (e.g., ensure the correct environment variable is set)."
24
+ )
25
+
26
+ self.api_key = credentials
27
+ logger.info("SERP API Key successfully retrieved via integration.")
28
+
29
+ async def search(self, params: dict[str, any] = None) -> str:
30
+ """Perform a search on the specified engine using SerpApi.
31
+
32
+ Args:
33
+ params: Dictionary of engine-specific parameters (e.g., {"q": "Coffee", "engine": "google_light", "location": "Austin, TX"}).
34
+
35
+ Returns:
36
+ A formatted string of search results or an error message.
37
+ """
38
+ if params is None:
39
+ params = {}
40
+ self._set_api_key()
41
+ params = {
42
+ "api_key": self.api_key,
43
+ "engine": "google_light", # Fastest engine by default
44
+ **params, # Include any additional parameters
45
+ }
46
+
47
+ try:
48
+ search = SerpApiSearch(params)
49
+ data = search.get_dict()
50
+
51
+ # Process organic search results if available
52
+ if "organic_results" in data:
53
+ formatted_results = []
54
+ for result in data.get("organic_results", []):
55
+ title = result.get("title", "No title")
56
+ link = result.get("link", "No link")
57
+ snippet = result.get("snippet", "No snippet")
58
+ formatted_results.append(
59
+ f"Title: {title}\nLink: {link}\nSnippet: {snippet}\n"
60
+ )
61
+ return (
62
+ "\n".join(formatted_results)
63
+ if formatted_results
64
+ else "No organic results found"
65
+ )
66
+ else:
67
+ return "No organic results found"
68
+
69
+ # Handle HTTP-specific errors
70
+ except httpx.HTTPStatusError as e:
71
+ if e.response.status_code == 429:
72
+ return "Error: Rate limit exceeded. Please try again later."
73
+ elif e.response.status_code == 401:
74
+ return "Error: Invalid API key. Please check your SERPAPI_API_KEY."
75
+ else:
76
+ return f"Error: {e.response.status_code} - {e.response.text}"
77
+ # Handle other exceptions (e.g., network issues)
78
+ except Exception as e:
79
+ return f"Error: {str(e)}"
80
+
81
+ def list_tools(self):
82
+ return [
83
+ self.search,
84
+ ]
@@ -1,27 +1,28 @@
1
1
  from universal_mcp.applications.application import APIApplication
2
2
  from universal_mcp.integrations import Integration
3
3
 
4
+
4
5
  class TavilyApp(APIApplication):
5
6
  def __init__(self, integration: Integration) -> None:
6
7
  name = "tavily"
7
8
  self.base_url = "https://api.tavily.com"
8
9
  super().__init__(name=name, integration=integration)
9
-
10
+
10
11
  def _get_headers(self):
11
12
  credentials = self.integration.get_credentials()
12
13
  if not credentials:
13
14
  raise ValueError("No credentials found")
14
15
  return {
15
16
  "Authorization": f"Bearer {credentials['api_key']}",
16
- "Content-Type": "application/json"
17
+ "Content-Type": "application/json",
17
18
  }
18
19
 
19
20
  def search(self, query: str) -> str:
20
21
  """Search the web using Tavily's search API
21
-
22
+
22
23
  Args:
23
24
  query: The search query
24
-
25
+
25
26
  Returns:
26
27
  str: A summary of search results
27
28
  """
@@ -37,21 +38,21 @@ class TavilyApp(APIApplication):
37
38
  "include_images": False,
38
39
  "include_image_descriptions": False,
39
40
  "include_domains": [],
40
- "exclude_domains": []
41
+ "exclude_domains": [],
41
42
  }
42
-
43
+
43
44
  response = self._post(url, payload)
44
45
  result = response.json()
45
-
46
+
46
47
  if "answer" in result:
47
48
  return result["answer"]
48
-
49
+
49
50
  # Fallback to combining top results if no direct answer
50
51
  summaries = []
51
52
  for item in result.get("results", [])[:3]:
52
53
  summaries.append(f"• {item['title']}: {item['snippet']}")
53
-
54
+
54
55
  return "\n".join(summaries)
55
-
56
+
56
57
  def list_tools(self):
57
58
  return [self.search]
@@ -1,13 +1,13 @@
1
1
  from universal_mcp.applications.application import APIApplication
2
2
 
3
3
 
4
- class ZenQuoteApp(APIApplication):
4
+ class ZenquotesApp(APIApplication):
5
5
  def __init__(self, **kwargs) -> None:
6
6
  super().__init__(name="zenquote", **kwargs)
7
7
 
8
8
  def get_quote(self) -> str:
9
9
  """Get an inspirational quote from the Zen Quotes API
10
-
10
+
11
11
  Returns:
12
12
  A random inspirational quote
13
13
  """
@@ -18,4 +18,4 @@ class ZenQuoteApp(APIApplication):
18
18
  return f"{quote_data['q']} - {quote_data['a']}"
19
19
 
20
20
  def list_tools(self):
21
- return [self.get_quote]
21
+ return [self.get_quote]