# Directory Structure
```
├── .gitignore
├── .python-version
├── images
│ └── demo.mov
├── LICENSE
├── pyproject.toml
├── README.md
├── src
│ └── gmail
│ ├── __init__.py
│ └── server.py
└── uv.lock
```
# Files
--------------------------------------------------------------------------------
/.python-version:
--------------------------------------------------------------------------------
```
1 | 3.12
2 |
```
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
```
1 | # Python-generated files
2 | __pycache__/
3 | *.py[oc]
4 | build/
5 | dist/
6 | wheels/
7 | *.egg-info
8 | *.DS_Store
9 |
10 | # Virtual environments
11 | .venv
12 |
```
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
```markdown
1 | # Gmail Server for Model Context Protocol (MCP)
2 |
3 | This MCP server integrates with Gmail to enable sending, removing, reading, drafting, and responding to emails.
4 |
5 | > Note: This server enables an MCP client to read, remove, and send emails. However, the client prompts the user before conducting such activities.
6 |
7 | https://github.com/user-attachments/assets/5794cd16-00d2-45a2-884a-8ba0c3a90c90
8 |
9 |
10 | ## Components
11 |
12 | ### Tools
13 |
14 | - **send-email**
15 | - Sends email to email address recipient
16 | - Input:
17 | - `recipient_id` (string): Email address of addressee
18 | - `subject` (string): Email subject
19 | - `message` (string): Email content
20 | - Returns status and message_id
21 |
22 | - **trash-email**
23 | - Moves email to trash
24 | - Input:
25 | - `email_id` (string): Auto-generated ID of email
26 | - Returns success message
27 |
28 | - **mark-email-as-read**
29 | - Marks email as read
30 | - Input:
31 | - `email_id` (string): Auto-generated ID of email
32 | - Returns success message
33 |
34 | - **get-unread-emails**
35 | - Retrieves unread emails
36 | - Returns list of emails including email ID
37 |
38 | - **read-email**
39 | - Retrieves given email content
40 | - Input:
41 | - `email_id` (string): Auto-generated ID of email
42 | - Returns dictionary of email metadata and marks email as read
43 |
44 | - **open-email**
45 | - Open email in browser
46 | - Input:
47 | - `email_id` (string): Auto-generated ID of email
48 | - Returns success message and opens given email in default browser
49 |
50 |
51 | ## Setup
52 |
53 | ### Gmail API Setup
54 |
55 | 1. [Create a new Google Cloud project](https://console.cloud.google.com/projectcreate)
56 | 2. [Enable the Gmail API](https://console.cloud.google.com/workspace-api/products)
57 | 3. [Configure an OAuth consent screen](https://console.cloud.google.com/apis/credentials/consent)
58 | - Select "external". However, we will not publish the app.
59 | - Add your personal email address as a "Test user".
60 | 4. Add OAuth scope `https://www.googleapis.com/auth/gmail/modify`
61 | 5. [Create an OAuth Client ID](https://console.cloud.google.com/apis/credentials/oauthclient) for application type "Desktop App"
62 | 6. Download the JSON file of your client's OAuth keys
63 | 7. Rename the key file and save it to your local machine in a secure location. Take note of the location.
64 | - The absolute path to this file will be passed as parameter `--creds-file-path` when the server is started.
65 |
66 | ### Authentication
67 |
68 | When the server is started, an authentication flow will be launched in your system browser.
69 | Token credentials will be subsequently saved (and later retrieved) in the absolute file path passed to parameter `--token-path`.
70 |
71 | For example, you may use a dot directory in your home folder, replacing `[your-home-folder]`.:
72 |
73 | | Parameter | Example |
74 | |-----------------|--------------------------------------------------|
75 | | `--creds-file-path` | `/[your-home-folder]/.google/client_creds.json` |
76 | | `--token-path` | `/[your-home-folder]/.google/app_tokens.json` |
77 |
78 |
79 | ### Usage with Desktop App
80 |
81 | Using [uv](https://docs.astral.sh/uv/) is recommended.
82 |
83 | To integrate this server with Claude Desktop as the MCP Client, add the following to your app's server configuration. By default, this is stored as `~/Library/Application\ Support/Claude/claude_desktop_config.json`.
84 |
85 | ```json
86 | {
87 | "mcpServers": {
88 | "gdrive": {
89 | "command": "uv",
90 | "args": [
91 | "--directory",
92 | "[absolute-path-to-git-repo]",
93 | "run",
94 | "gmail",
95 | "--creds-file-path",
96 | "[absolute-path-to-credentials-file]",
97 | "--token-path",
98 | "[absolute-path-to-access-tokens-file]"
99 | ]
100 | }
101 | }
102 | }
103 | ```
104 |
105 | The following parameters must be set
106 | | Parameter | Example |
107 | |-----------------|--------------------------------------------------|
108 | | `--directory` | Absolute path to `gmail` directory containing server |
109 | | `--creds-file-path` | Absolute path to credentials file created in Gmail API Setup. |
110 | | `--token-path` | Absolute path to store and retrieve access and refresh tokens for application. |
111 |
112 | ### Troubleshooting with MCP Inspector
113 |
114 | To test the server, use [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector).
115 | From the git repo, run the below changing the parameter arguments accordingly.
116 |
117 | ```bash
118 | npx @modelcontextprotocol/inspector uv run [absolute-path-to-git-repo]/src/gmail/server.py --creds-file-path [absolute-path-to-credentials-file] --token-path [absolute-path-to-access-tokens-file]
119 | ```
120 |
121 |
```
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
```toml
1 | [project]
2 | name = "gmail"
3 | version = "0.1.0"
4 | description = "Model Context Protocol server for gmail"
5 | readme = "README.md"
6 | requires-python = ">=3.12"
7 | dependencies = [
8 | "httpx>=0.28.1",
9 | "mcp>=1.1.2",
10 | "google-api-python-client>=2.156.0",
11 | "google-auth-httplib2>=0.2.0",
12 | "google-auth-oauthlib>=1.2.1",
13 | ]
14 | [build-system]
15 | requires = [ "hatchling",]
16 | build-backend = "hatchling.build"
17 |
18 | [project.scripts]
19 | gmail = "gmail:main"
20 |
```
--------------------------------------------------------------------------------
/src/gmail/__init__.py:
--------------------------------------------------------------------------------
```python
1 | from . import server
2 | import asyncio
3 | import argparse
4 |
5 | def main():
6 | """Main entry point for the package."""
7 | parser = argparse.ArgumentParser(description='Gmail API MCP Server')
8 | parser.add_argument('--creds-file-path',
9 | required=True,
10 | help='OAuth 2.0 credentials file path')
11 | parser.add_argument('--token-path',
12 | required=True,
13 | help='File location to store and retrieve access and refresh tokens for application')
14 |
15 | args = parser.parse_args()
16 | asyncio.run(server.main(args.creds_file_path, args.token_path))
17 |
18 | # Optionally expose other important items at package level
19 | __all__ = ['main', 'server']
20 |
```
--------------------------------------------------------------------------------
/src/gmail/server.py:
--------------------------------------------------------------------------------
```python
1 | from typing import Any
2 | import argparse
3 | import os
4 | import asyncio
5 | import logging
6 | import base64
7 | from email.message import EmailMessage
8 | from email.header import decode_header
9 | from base64 import urlsafe_b64decode
10 | from email import message_from_bytes
11 | import webbrowser
12 |
13 | from mcp.server.models import InitializationOptions
14 | import mcp.types as types
15 | from mcp.server import NotificationOptions, Server
16 | import mcp.server.stdio
17 |
18 |
19 | from google.auth.transport.requests import Request
20 | from google.oauth2.credentials import Credentials
21 | from google_auth_oauthlib.flow import InstalledAppFlow
22 | from googleapiclient.discovery import build
23 | from googleapiclient.errors import HttpError
24 |
25 |
26 | # Configure logging
27 | logging.basicConfig(level=logging.INFO)
28 | logger = logging.getLogger(__name__)
29 |
30 | EMAIL_ADMIN_PROMPTS = """You are an email administrator.
31 | You can draft, edit, read, trash, open, and send emails.
32 | You've been given access to a specific gmail account.
33 | You have the following tools available:
34 | - Send an email (send-email)
35 | - Retrieve unread emails (get-unread-emails)
36 | - Read email content (read-email)
37 | - Trash email (tras-email)
38 | - Open email in browser (open-email)
39 | Never send an email draft or trash an email unless the user confirms first.
40 | Always ask for approval if not already given.
41 | """
42 |
43 | # Define available prompts
44 | PROMPTS = {
45 | "manage-email": types.Prompt(
46 | name="manage-email",
47 | description="Act like an email administator",
48 | arguments=None,
49 | ),
50 | "draft-email": types.Prompt(
51 | name="draft-email",
52 | description="Draft an email with cotent and recipient",
53 | arguments=[
54 | types.PromptArgument(
55 | name="content",
56 | description="What the email is about",
57 | required=True
58 | ),
59 | types.PromptArgument(
60 | name="recipient",
61 | description="Who should the email be addressed to",
62 | required=True
63 | ),
64 | types.PromptArgument(
65 | name="recipient_email",
66 | description="Recipient's email address",
67 | required=True
68 | ),
69 | ],
70 | ),
71 | "edit-draft": types.Prompt(
72 | name="edit-draft",
73 | description="Edit the existing email draft",
74 | arguments=[
75 | types.PromptArgument(
76 | name="changes",
77 | description="What changes should be made to the draft",
78 | required=True
79 | ),
80 | types.PromptArgument(
81 | name="current_draft",
82 | description="The current draft to edit",
83 | required=True
84 | ),
85 | ],
86 | ),
87 | }
88 |
89 |
90 | def decode_mime_header(header: str) -> str:
91 | """Helper function to decode encoded email headers"""
92 |
93 | decoded_parts = decode_header(header)
94 | decoded_string = ''
95 | for part, encoding in decoded_parts:
96 | if isinstance(part, bytes):
97 | # Decode bytes to string using the specified encoding
98 | decoded_string += part.decode(encoding or 'utf-8')
99 | else:
100 | # Already a string
101 | decoded_string += part
102 | return decoded_string
103 |
104 |
105 | class GmailService:
106 | def __init__(self,
107 | creds_file_path: str,
108 | token_path: str,
109 | scopes: list[str] = ['https://www.googleapis.com/auth/gmail.modify']):
110 | logger.info(f"Initializing GmailService with creds file: {creds_file_path}")
111 | self.creds_file_path = creds_file_path
112 | self.token_path = token_path
113 | self.scopes = scopes
114 | self.token = self._get_token()
115 | logger.info("Token retrieved successfully")
116 | self.service = self._get_service()
117 | logger.info("Gmail service initialized")
118 | self.user_email = self._get_user_email()
119 | logger.info(f"User email retrieved: {self.user_email}")
120 |
121 | def _get_token(self) -> Credentials:
122 | """Get or refresh Google API token"""
123 |
124 | token = None
125 |
126 | if os.path.exists(self.token_path):
127 | logger.info('Loading token from file')
128 | token = Credentials.from_authorized_user_file(self.token_path, self.scopes)
129 |
130 | if not token or not token.valid:
131 | if token and token.expired and token.refresh_token:
132 | logger.info('Refreshing token')
133 | token.refresh(Request())
134 | else:
135 | logger.info('Fetching new token')
136 | flow = InstalledAppFlow.from_client_secrets_file(self.creds_file_path, self.scopes)
137 | token = flow.run_local_server(port=0)
138 |
139 | with open(self.token_path, 'w') as token_file:
140 | token_file.write(token.to_json())
141 | logger.info(f'Token saved to {self.token_path}')
142 |
143 | return token
144 |
145 | def _get_service(self) -> Any:
146 | """Initialize Gmail API service"""
147 | try:
148 | service = build('gmail', 'v1', credentials=self.token)
149 | return service
150 | except HttpError as error:
151 | logger.error(f'An error occurred building Gmail service: {error}')
152 | raise ValueError(f'An error occurred: {error}')
153 |
154 | def _get_user_email(self) -> str:
155 | """Get user email address"""
156 | profile = self.service.users().getProfile(userId='me').execute()
157 | user_email = profile.get('emailAddress', '')
158 | return user_email
159 |
160 | async def send_email(self, recipient_id: str, subject: str, message: str,) -> dict:
161 | """Creates and sends an email message"""
162 | try:
163 | message_obj = EmailMessage()
164 | message_obj.set_content(message)
165 |
166 | message_obj['To'] = recipient_id
167 | message_obj['From'] = self.user_email
168 | message_obj['Subject'] = subject
169 |
170 | encoded_message = base64.urlsafe_b64encode(message_obj.as_bytes()).decode()
171 | create_message = {'raw': encoded_message}
172 |
173 | send_message = await asyncio.to_thread(
174 | self.service.users().messages().send(userId="me", body=create_message).execute
175 | )
176 | logger.info(f"Message sent: {send_message['id']}")
177 | return {"status": "success", "message_id": send_message["id"]}
178 | except HttpError as error:
179 | return {"status": "error", "error_message": str(error)}
180 |
181 | async def open_email(self, email_id: str) -> str:
182 | """Opens email in browser given ID."""
183 | try:
184 | url = f"https://mail.google.com/#all/{email_id}"
185 | webbrowser.open(url, new=0, autoraise=True)
186 | return "Email opened in browser successfully."
187 | except HttpError as error:
188 | return f"An HttpError occurred: {str(error)}"
189 |
190 | async def get_unread_emails(self) -> list[dict[str, str]]| str:
191 | """
192 | Retrieves unread messages from mailbox.
193 | Returns list of messsage IDs in key 'id'."""
194 | try:
195 | user_id = 'me'
196 | query = 'in:inbox is:unread category:primary'
197 |
198 | response = self.service.users().messages().list(userId=user_id,
199 | q=query).execute()
200 | messages = []
201 | if 'messages' in response:
202 | messages.extend(response['messages'])
203 |
204 | while 'nextPageToken' in response:
205 | page_token = response['nextPageToken']
206 | response = self.service.users().messages().list(userId=user_id, q=query,
207 | pageToken=page_token).execute()
208 | messages.extend(response['messages'])
209 | return messages
210 |
211 | except HttpError as error:
212 | return f"An HttpError occurred: {str(error)}"
213 |
214 | async def read_email(self, email_id: str) -> dict[str, str]| str:
215 | """Retrieves email contents including to, from, subject, and contents."""
216 | try:
217 | msg = self.service.users().messages().get(userId="me", id=email_id, format='raw').execute()
218 | email_metadata = {}
219 |
220 | # Decode the base64URL encoded raw content
221 | raw_data = msg['raw']
222 | decoded_data = urlsafe_b64decode(raw_data)
223 |
224 | # Parse the RFC 2822 email
225 | mime_message = message_from_bytes(decoded_data)
226 |
227 | # Extract the email body
228 | body = None
229 | if mime_message.is_multipart():
230 | for part in mime_message.walk():
231 | # Extract the text/plain part
232 | if part.get_content_type() == "text/plain":
233 | body = part.get_payload(decode=True).decode()
234 | break
235 | else:
236 | # For non-multipart messages
237 | body = mime_message.get_payload(decode=True).decode()
238 | email_metadata['content'] = body
239 |
240 | # Extract metadata
241 | email_metadata['subject'] = decode_mime_header(mime_message.get('subject', ''))
242 | email_metadata['from'] = mime_message.get('from','')
243 | email_metadata['to'] = mime_message.get('to','')
244 | email_metadata['date'] = mime_message.get('date','')
245 |
246 | logger.info(f"Email read: {email_id}")
247 |
248 | # We want to mark email as read once we read it
249 | await self.mark_email_as_read(email_id)
250 |
251 | return email_metadata
252 | except HttpError as error:
253 | return f"An HttpError occurred: {str(error)}"
254 |
255 | async def trash_email(self, email_id: str) -> str:
256 | """Moves email to trash given ID."""
257 | try:
258 | self.service.users().messages().trash(userId="me", id=email_id).execute()
259 | logger.info(f"Email moved to trash: {email_id}")
260 | return "Email moved to trash successfully."
261 | except HttpError as error:
262 | return f"An HttpError occurred: {str(error)}"
263 |
264 | async def mark_email_as_read(self, email_id: str) -> str:
265 | """Marks email as read given ID."""
266 | try:
267 | self.service.users().messages().modify(userId="me", id=email_id, body={'removeLabelIds': ['UNREAD']}).execute()
268 | logger.info(f"Email marked as read: {email_id}")
269 | return "Email marked as read."
270 | except HttpError as error:
271 | return f"An HttpError occurred: {str(error)}"
272 |
273 | async def main(creds_file_path: str,
274 | token_path: str):
275 |
276 | gmail_service = GmailService(creds_file_path, token_path)
277 | server = Server("gmail")
278 |
279 | @server.list_prompts()
280 | async def list_prompts() -> list[types.Prompt]:
281 | return list(PROMPTS.values())
282 |
283 | @server.get_prompt()
284 | async def get_prompt(
285 | name: str, arguments: dict[str, str] | None = None
286 | ) -> types.GetPromptResult:
287 | if name not in PROMPTS:
288 | raise ValueError(f"Prompt not found: {name}")
289 |
290 | if name == "manage-email":
291 | return types.GetPromptResult(
292 | messages=[
293 | types.PromptMessage(
294 | role="user",
295 | content=types.TextContent(
296 | type="text",
297 | text=EMAIL_ADMIN_PROMPTS,
298 | )
299 | )
300 | ]
301 | )
302 |
303 | if name == "draft-email":
304 | content = arguments.get("content", "")
305 | recipient = arguments.get("recipient", "")
306 | recipient_email = arguments.get("recipient_email", "")
307 |
308 | # First message asks the LLM to create the draft
309 | return types.GetPromptResult(
310 | messages=[
311 | types.PromptMessage(
312 | role="user",
313 | content=types.TextContent(
314 | type="text",
315 | text=f"""Please draft an email about {content} for {recipient} ({recipient_email}).
316 | Include a subject line starting with 'Subject:' on the first line.
317 | Do not send the email yet, just draft it and ask the user for their thoughts."""
318 | )
319 | )
320 | ]
321 | )
322 |
323 | elif name == "edit-draft":
324 | changes = arguments.get("changes", "")
325 | current_draft = arguments.get("current_draft", "")
326 |
327 | # Edit existing draft based on requested changes
328 | return types.GetPromptResult(
329 | messages=[
330 | types.PromptMessage(
331 | role="user",
332 | content=types.TextContent(
333 | type="text",
334 | text=f"""Please revise the current email draft:
335 | {current_draft}
336 |
337 | Requested changes:
338 | {changes}
339 |
340 | Please provide the updated draft."""
341 | )
342 | )
343 | ]
344 | )
345 |
346 | raise ValueError("Prompt implementation not found")
347 |
348 | @server.list_tools()
349 | async def handle_list_tools() -> list[types.Tool]:
350 | return [
351 | types.Tool(
352 | name="send-email",
353 | description="""Sends email to recipient.
354 | Do not use if user only asked to draft email.
355 | Drafts must be approved before sending.""",
356 | inputSchema={
357 | "type": "object",
358 | "properties": {
359 | "recipient_id": {
360 | "type": "string",
361 | "description": "Recipient email address",
362 | },
363 | "subject": {
364 | "type": "string",
365 | "description": "Email subject",
366 | },
367 | "message": {
368 | "type": "string",
369 | "description": "Email content text",
370 | },
371 | },
372 | "required": ["recipient_id", "subject", "message"],
373 | },
374 | ),
375 | types.Tool(
376 | name="trash-email",
377 | description="""Moves email to trash.
378 | Confirm before moving email to trash.""",
379 | inputSchema={
380 | "type": "object",
381 | "properties": {
382 | "email_id": {
383 | "type": "string",
384 | "description": "Email ID",
385 | },
386 | },
387 | "required": ["email_id"],
388 | },
389 | ),
390 | types.Tool(
391 | name="get-unread-emails",
392 | description="Retrieve unread emails",
393 | inputSchema={
394 | "type": "object",
395 | "properties": {"":""},
396 | "required": None
397 | },
398 | ),
399 | types.Tool(
400 | name="read-email",
401 | description="Retrieves given email content",
402 | inputSchema={
403 | "type": "object",
404 | "properties": {
405 | "email_id": {
406 | "type": "string",
407 | "description": "Email ID",
408 | },
409 | },
410 | "required": ["email_id"],
411 | },
412 | ),
413 | types.Tool(
414 | name="mark-email-as-read",
415 | description="Marks given email as read",
416 | inputSchema={
417 | "type": "object",
418 | "properties": {
419 | "email_id": {
420 | "type": "string",
421 | "description": "Email ID",
422 | },
423 | },
424 | "required": ["email_id"],
425 | },
426 | ),
427 | types.Tool(
428 | name="open-email",
429 | description="Open email in browser",
430 | inputSchema={
431 | "type": "object",
432 | "properties": {
433 | "email_id": {
434 | "type": "string",
435 | "description": "Email ID",
436 | },
437 | },
438 | "required": ["email_id"],
439 | },
440 | ),
441 | ]
442 |
443 | @server.call_tool()
444 | async def handle_call_tool(
445 | name: str, arguments: dict | None
446 | ) -> list[types.TextContent | types.ImageContent | types.EmbeddedResource]:
447 |
448 | if name == "send-email":
449 | recipient = arguments.get("recipient_id")
450 | if not recipient:
451 | raise ValueError("Missing recipient parameter")
452 | subject = arguments.get("subject")
453 | if not subject:
454 | raise ValueError("Missing subject parameter")
455 | message = arguments.get("message")
456 | if not message:
457 | raise ValueError("Missing message parameter")
458 |
459 | # Extract subject and message content
460 | email_lines = message.split('\n')
461 | if email_lines[0].startswith('Subject:'):
462 | subject = email_lines[0][8:].strip()
463 | message_content = '\n'.join(email_lines[1:]).strip()
464 | else:
465 | message_content = message
466 |
467 | send_response = await gmail_service.send_email(recipient, subject, message_content)
468 |
469 | if send_response["status"] == "success":
470 | response_text = f"Email sent successfully. Message ID: {send_response['message_id']}"
471 | else:
472 | response_text = f"Failed to send email: {send_response['error_message']}"
473 | return [types.TextContent(type="text", text=response_text)]
474 |
475 | if name == "get-unread-emails":
476 |
477 | unread_emails = await gmail_service.get_unread_emails()
478 | return [types.TextContent(type="text", text=str(unread_emails),artifact={"type": "json", "data": unread_emails} )]
479 |
480 | if name == "read-email":
481 | email_id = arguments.get("email_id")
482 | if not email_id:
483 | raise ValueError("Missing email ID parameter")
484 |
485 | retrieved_email = await gmail_service.read_email(email_id)
486 | return [types.TextContent(type="text", text=str(retrieved_email),artifact={"type": "dictionary", "data": retrieved_email} )]
487 | if name == "open-email":
488 | email_id = arguments.get("email_id")
489 | if not email_id:
490 | raise ValueError("Missing email ID parameter")
491 |
492 | msg = await gmail_service.open_email(email_id)
493 | return [types.TextContent(type="text", text=str(msg))]
494 | if name == "trash-email":
495 | email_id = arguments.get("email_id")
496 | if not email_id:
497 | raise ValueError("Missing email ID parameter")
498 |
499 | msg = await gmail_service.trash_email(email_id)
500 | return [types.TextContent(type="text", text=str(msg))]
501 | if name == "mark-email-as-read":
502 | email_id = arguments.get("email_id")
503 | if not email_id:
504 | raise ValueError("Missing email ID parameter")
505 |
506 | msg = await gmail_service.mark_email_as_read(email_id)
507 | return [types.TextContent(type="text", text=str(msg))]
508 | else:
509 | logger.error(f"Unknown tool: {name}")
510 | raise ValueError(f"Unknown tool: {name}")
511 |
512 | async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
513 | await server.run(
514 | read_stream,
515 | write_stream,
516 | InitializationOptions(
517 | server_name="gmail",
518 | server_version="0.1.0",
519 | capabilities=server.get_capabilities(
520 | notification_options=NotificationOptions(),
521 | experimental_capabilities={},
522 | ),
523 | ),
524 | )
525 |
526 | if __name__ == "__main__":
527 | parser = argparse.ArgumentParser(description='Gmail API MCP Server')
528 | parser.add_argument('--creds-file-path',
529 | required=True,
530 | help='OAuth 2.0 credentials file path')
531 | parser.add_argument('--token-path',
532 | required=True,
533 | help='File location to store and retrieve access and refresh tokens for application')
534 |
535 | args = parser.parse_args()
536 | asyncio.run(main(args.creds_file_path, args.token_path))
```