2025-05-12 17:22:58 -04:00
"""
Google Gmail MCP Tools
This module provides MCP tools for interacting with the Gmail API.
"""
2025-05-22 14:21:52 -04:00
2025-05-12 17:22:58 -04:00
import logging
import asyncio
import base64
2025-05-20 10:59:04 -04:00
from typing import Optional
2025-05-12 17:22:58 -04:00
2025-05-19 20:57:11 -07:00
from email.mime.text import MIMEText
2025-05-12 17:22:58 -04:00
from mcp import types
2025-05-24 10:43:55 -04:00
from fastapi import Body
2025-05-12 17:22:58 -04:00
from googleapiclient.errors import HttpError
2025-05-24 10:43:55 -04:00
from auth.google_auth import get_authenticated_google_service
2025-05-13 12:36:53 -04:00
2025-05-12 17:22:58 -04:00
from core.server import (
GMAIL_READONLY_SCOPE ,
GMAIL_SEND_SCOPE ,
2025-05-22 00:02:51 +05:30
GMAIL_COMPOSE_SCOPE ,
2025-05-22 14:21:52 -04:00
server ,
2025-05-12 17:22:58 -04:00
)
logger = logging . getLogger ( __name__ )
2025-05-22 14:21:52 -04:00
2025-05-24 00:08:19 +08:00
def _extract_message_body ( payload ):
"""
Helper function to extract plain text body from a Gmail message payload.
Args:
payload (dict): The message payload from Gmail API
Returns:
str: The plain text body content, or empty string if not found
"""
body_data = ""
parts = [ payload ] if "parts" not in payload else payload . get ( "parts" , [])
part_queue = list ( parts ) # Use a queue for BFS traversal of parts
while part_queue :
part = part_queue . pop ( 0 )
if part . get ( "mimeType" ) == "text/plain" and part . get ( "body" , {}) . get ( "data" ):
data = base64 . urlsafe_b64decode ( part [ "body" ][ "data" ])
body_data = data . decode ( "utf-8" , errors = "ignore" )
break # Found plain text body
elif part . get ( "mimeType" , "" ) . startswith ( "multipart/" ) and "parts" in part :
part_queue . extend ( part . get ( "parts" , [])) # Add sub-parts to the queue
# If no plain text found, check the main payload body if it exists
if (
not body_data
and payload . get ( "mimeType" ) == "text/plain"
and payload . get ( "body" , {}) . get ( "data" )
):
data = base64 . urlsafe_b64decode ( payload [ "body" ][ "data" ])
body_data = data . decode ( "utf-8" , errors = "ignore" )
return body_data
2025-05-25 17:35:58 +08:00
def _generate_gmail_web_url ( item_id : str , account_index : int = 0 ) -> str :
"""
Generate Gmail web interface URL for a message or thread ID.
Uses #all to access messages from any Gmail folder/label (not just inbox).
Args:
item_id: Gmail message ID or thread ID
account_index: Google account index (default 0 for primary account)
Returns:
Gmail web interface URL that opens the message/thread in Gmail web interface
"""
return f "https://mail.google.com/mail/u/ { account_index } /#all/ { item_id } "
2025-05-26 17:51:34 -04:00
def _format_gmail_results_plain ( messages : list , query : str ) -> str :
"""Format Gmail search results in clean, LLM-friendly plain text."""
if not messages :
return f "No messages found for query: ' { query } '"
lines = [
f "Found { len ( messages ) } messages matching ' { query } ':" ,
"" ,
"📧 MESSAGES:" ,
]
for i , msg in enumerate ( messages , 1 ):
message_url = _generate_gmail_web_url ( msg [ "id" ])
thread_url = _generate_gmail_web_url ( msg [ "threadId" ])
lines . extend ([
f " { i } . Message ID: { msg [ 'id' ] } " ,
f " Web Link: { message_url } " ,
f " Thread ID: { msg [ 'threadId' ] } " ,
f " Thread Link: { thread_url } " ,
""
])
lines . extend ([
"💡 USAGE:" ,
" • Use Message ID with get_gmail_message_content()" ,
" • Use Thread ID with get_gmail_thread_content()" ,
" • Click web links to view in Gmail browser interface"
])
return " \n " . join ( lines )
2025-05-12 17:22:58 -04:00
@server.tool ()
2025-05-20 10:43:47 -04:00
async def search_gmail_messages (
2025-05-12 17:22:58 -04:00
query : str ,
2025-05-24 10:43:55 -04:00
user_google_email : str ,
2025-05-12 17:22:58 -04:00
page_size : int = 10 ,
) -> types . CallToolResult :
"""
Searches messages in a user's Gmail account based on a query.
2025-05-25 17:35:58 +08:00
Returns both Message IDs and Thread IDs for each found message, along with Gmail web interface links for manual verification.
2025-05-12 17:22:58 -04:00
Args:
query (str): The search query. Supports standard Gmail search operators.
2025-05-24 10:43:55 -04:00
user_google_email (str): The user's Google email address. Required.
2025-05-12 17:22:58 -04:00
page_size (int): The maximum number of messages to return. Defaults to 10.
Returns:
2025-05-26 17:51:34 -04:00
types.CallToolResult: Contains LLM-friendly structured results with Message IDs, Thread IDs, and clickable Gmail web interface URLs for each found message, or an error/auth guidance message.
2025-05-12 17:22:58 -04:00
"""
tool_name = "search_gmail_messages"
2025-05-22 14:21:52 -04:00
logger . info (
2025-05-24 10:43:55 -04:00
f "[ { tool_name } ] Invoked. Email: ' { user_google_email } ', Query: ' { query } '"
2025-05-22 14:21:52 -04:00
)
2025-05-13 12:36:53 -04:00
2025-05-24 10:43:55 -04:00
auth_result = await get_authenticated_google_service (
service_name = "gmail" ,
version = "v1" ,
tool_name = tool_name ,
2025-05-13 12:36:53 -04:00
user_google_email = user_google_email ,
2025-05-20 10:43:47 -04:00
required_scopes = [ GMAIL_READONLY_SCOPE ],
2025-05-13 12:36:53 -04:00
)
2025-05-24 10:43:55 -04:00
if isinstance ( auth_result , types . CallToolResult ):
return auth_result # Auth error
service , user_email = auth_result
2025-05-12 17:22:58 -04:00
try :
response = await asyncio . to_thread (
2025-05-22 14:21:52 -04:00
service . users ()
. messages ()
. list ( userId = "me" , q = query , maxResults = page_size )
. execute
2025-05-12 17:22:58 -04:00
)
2025-05-22 14:21:52 -04:00
messages = response . get ( "messages" , [])
2025-05-26 17:51:34 -04:00
formatted_output = _format_gmail_results_plain ( messages , query )
2025-05-25 17:35:58 +08:00
2025-05-22 14:21:52 -04:00
return types . CallToolResult (
2025-05-26 17:51:34 -04:00
content = [ types . TextContent ( type = "text" , text = formatted_output )]
2025-05-22 14:21:52 -04:00
)
2025-05-12 17:22:58 -04:00
except HttpError as e :
2025-05-22 14:21:52 -04:00
logger . error (
f "[ { tool_name } ] Gmail API error searching messages: { e } " , exc_info = True
)
return types . CallToolResult (
isError = True ,
content = [ types . TextContent ( type = "text" , text = f "Gmail API error: { e } " )],
)
2025-05-12 17:22:58 -04:00
except Exception as e :
2025-05-22 14:21:52 -04:00
logger . exception (
f "[ { tool_name } ] Unexpected error searching Gmail messages: { e } "
)
return types . CallToolResult (
isError = True ,
content = [ types . TextContent ( type = "text" , text = f "Unexpected error: { e } " )],
)
2025-05-12 17:22:58 -04:00
@server.tool ()
2025-05-20 10:43:47 -04:00
async def get_gmail_message_content (
2025-05-12 17:22:58 -04:00
message_id : str ,
2025-05-24 10:43:55 -04:00
user_google_email : str ,
2025-05-12 17:22:58 -04:00
) -> types . CallToolResult :
"""
Retrieves the full content (subject, sender, plain text body) of a specific Gmail message.
Args:
message_id (str): The unique ID of the Gmail message to retrieve.
2025-05-24 10:43:55 -04:00
user_google_email (str): The user's Google email address. Required.
2025-05-12 17:22:58 -04:00
Returns:
types.CallToolResult: Contains the message details or an error/auth guidance message.
"""
tool_name = "get_gmail_message_content"
2025-05-22 14:21:52 -04:00
logger . info (
2025-05-24 10:43:55 -04:00
f "[ { tool_name } ] Invoked. Message ID: ' { message_id } ', Email: ' { user_google_email } '"
2025-05-22 14:21:52 -04:00
)
2025-05-13 12:36:53 -04:00
2025-05-24 10:43:55 -04:00
auth_result = await get_authenticated_google_service (
service_name = "gmail" ,
version = "v1" ,
tool_name = tool_name ,
2025-05-13 12:36:53 -04:00
user_google_email = user_google_email ,
2025-05-20 10:43:47 -04:00
required_scopes = [ GMAIL_READONLY_SCOPE ],
2025-05-13 12:36:53 -04:00
)
2025-05-24 10:43:55 -04:00
if isinstance ( auth_result , types . CallToolResult ):
return auth_result # Auth error
service , user_email = auth_result
2025-05-12 17:22:58 -04:00
try :
2025-05-24 10:43:55 -04:00
logger . info ( f "[ { tool_name } ] Using service for: { user_google_email } " )
2025-05-12 17:22:58 -04:00
# Fetch message metadata first to get headers
message_metadata = await asyncio . to_thread (
2025-05-22 14:21:52 -04:00
service . users ()
. messages ()
. get (
userId = "me" ,
2025-05-12 17:22:58 -04:00
id = message_id ,
2025-05-22 14:21:52 -04:00
format = "metadata" ,
metadataHeaders = [ "Subject" , "From" ],
)
. execute
2025-05-12 17:22:58 -04:00
)
2025-05-22 14:21:52 -04:00
headers = {
h [ "name" ]: h [ "value" ]
for h in message_metadata . get ( "payload" , {}) . get ( "headers" , [])
}
subject = headers . get ( "Subject" , "(no subject)" )
sender = headers . get ( "From" , "(unknown sender)" )
2025-05-12 17:22:58 -04:00
# Now fetch the full message to get the body parts
message_full = await asyncio . to_thread (
2025-05-22 14:21:52 -04:00
service . users ()
. messages ()
. get (
userId = "me" ,
2025-05-12 17:22:58 -04:00
id = message_id ,
2025-05-22 14:21:52 -04:00
format = "full" , # Request full payload for body
)
. execute
2025-05-12 17:22:58 -04:00
)
2025-05-24 00:08:19 +08:00
# Extract the plain text body using helper function
2025-05-22 14:21:52 -04:00
payload = message_full . get ( "payload" , {})
2025-05-24 00:08:19 +08:00
body_data = _extract_message_body ( payload )
2025-05-22 14:21:52 -04:00
content_text = " \n " . join (
[
f "Subject: { subject } " ,
f "From: { sender } " ,
f " \n --- BODY --- \n { body_data or '[No text/plain body found]' } " ,
]
)
return types . CallToolResult (
content = [ types . TextContent ( type = "text" , text = content_text )]
)
2025-05-12 17:22:58 -04:00
except HttpError as e :
2025-05-22 14:21:52 -04:00
logger . error (
f "[ { tool_name } ] Gmail API error getting message content: { e } " , exc_info = True
)
return types . CallToolResult (
isError = True ,
content = [ types . TextContent ( type = "text" , text = f "Gmail API error: { e } " )],
)
2025-05-12 17:22:58 -04:00
except Exception as e :
2025-05-22 14:21:52 -04:00
logger . exception (
f "[ { tool_name } ] Unexpected error getting Gmail message content: { e } "
)
return types . CallToolResult (
isError = True ,
content = [ types . TextContent ( type = "text" , text = f "Unexpected error: { e } " )],
)
2025-05-12 17:22:58 -04:00
2025-05-19 20:57:11 -07:00
@server.tool ()
async def send_gmail_message (
2025-05-24 10:43:55 -04:00
user_google_email : str ,
2025-05-19 20:57:11 -07:00
to : str = Body ( ... , description = "Recipient email address." ),
subject : str = Body ( ... , description = "Email subject." ),
body : str = Body ( ... , description = "Email body (plain text)." ),
) -> types . CallToolResult :
"""
Sends an email using the user's Gmail account.
Args:
to (str): Recipient email address.
subject (str): Email subject.
body (str): Email body (plain text).
2025-05-24 10:43:55 -04:00
user_google_email (str): The user's Google email address. Required.
2025-05-19 20:57:11 -07:00
Returns:
types.CallToolResult: Contains the message ID of the sent email, or an error/auth guidance message.
"""
tool_name = "send_gmail_message"
2025-05-24 10:43:55 -04:00
auth_result = await get_authenticated_google_service (
service_name = "gmail" ,
version = "v1" ,
tool_name = tool_name ,
user_google_email = user_google_email ,
required_scopes = [ GMAIL_SEND_SCOPE ],
)
if isinstance ( auth_result , types . CallToolResult ):
return auth_result # Auth error
service , user_email = auth_result
try :
2025-05-19 20:57:11 -07:00
# Prepare the email
message = MIMEText ( body )
message [ "to" ] = to
message [ "subject" ] = subject
raw_message = base64 . urlsafe_b64encode ( message . as_bytes ()) . decode ()
send_body = { "raw" : raw_message }
# Send the message
sent_message = await asyncio . to_thread (
service . users () . messages () . send ( userId = "me" , body = send_body ) . execute
)
message_id = sent_message . get ( "id" )
2025-05-22 14:21:52 -04:00
return types . CallToolResult (
content = [
types . TextContent (
type = "text" , text = f "Email sent! Message ID: { message_id } "
)
]
)
2025-05-19 20:57:11 -07:00
except HttpError as e :
2025-05-22 14:21:52 -04:00
logger . error (
f "[ { tool_name } ] Gmail API error sending message: { e } " , exc_info = True
)
return types . CallToolResult (
isError = True ,
content = [ types . TextContent ( type = "text" , text = f "Gmail API error: { e } " )],
)
2025-05-19 20:57:11 -07:00
except Exception as e :
logger . exception ( f "[ { tool_name } ] Unexpected error sending Gmail message: { e } " )
2025-05-22 14:21:52 -04:00
return types . CallToolResult (
isError = True ,
content = [ types . TextContent ( type = "text" , text = f "Unexpected error: { e } " )],
)
2025-05-22 00:02:51 +05:30
@server.tool ()
async def draft_gmail_message (
2025-05-24 10:43:55 -04:00
user_google_email : str ,
2025-05-22 00:02:51 +05:30
subject : str = Body ( ... , description = "Email subject." ),
body : str = Body ( ... , description = "Email body (plain text)." ),
to : Optional [ str ] = Body ( None , description = "Optional recipient email address." ),
) -> types . CallToolResult :
"""
Creates a draft email in the user's Gmail account.
Args:
2025-05-24 10:43:55 -04:00
user_google_email (str): The user's Google email address. Required.
2025-05-22 00:02:51 +05:30
subject (str): Email subject.
body (str): Email body (plain text).
to (Optional[str]): Optional recipient email address. Can be left empty for drafts.
Returns:
types.CallToolResult: Contains the draft ID of the created email, or an error/auth guidance message.
"""
tool_name = "draft_gmail_message"
2025-05-22 14:21:52 -04:00
logger . info (
2025-05-24 10:43:55 -04:00
f "[ { tool_name } ] Invoked. Email: ' { user_google_email } ', Subject: ' { subject } '"
2025-05-22 14:21:52 -04:00
)
2025-05-24 10:43:55 -04:00
auth_result = await get_authenticated_google_service (
service_name = "gmail" ,
version = "v1" ,
tool_name = tool_name ,
2025-05-22 14:21:52 -04:00
user_google_email = user_google_email ,
required_scopes = [ GMAIL_COMPOSE_SCOPE ],
)
2025-05-24 10:43:55 -04:00
if isinstance ( auth_result , types . CallToolResult ):
return auth_result # Auth error
service , user_email = auth_result
2025-05-22 00:02:51 +05:30
2025-05-22 14:21:52 -04:00
try :
2025-05-22 00:02:51 +05:30
# Prepare the email
message = MIMEText ( body )
message [ "subject" ] = subject
2025-05-22 14:21:52 -04:00
2025-05-22 00:02:51 +05:30
# Add recipient if provided
if to :
message [ "to" ] = to
2025-05-22 14:21:52 -04:00
2025-05-22 00:02:51 +05:30
raw_message = base64 . urlsafe_b64encode ( message . as_bytes ()) . decode ()
2025-05-22 14:21:52 -04:00
2025-05-22 00:02:51 +05:30
# Create a draft instead of sending
2025-05-22 14:21:52 -04:00
draft_body = { "message" : { "raw" : raw_message }}
2025-05-22 00:02:51 +05:30
# Create the draft
created_draft = await asyncio . to_thread (
service . users () . drafts () . create ( userId = "me" , body = draft_body ) . execute
)
draft_id = created_draft . get ( "id" )
2025-05-22 14:21:52 -04:00
return types . CallToolResult (
content = [
types . TextContent (
type = "text" , text = f "Draft created! Draft ID: { draft_id } "
)
]
)
2025-05-22 00:02:51 +05:30
except HttpError as e :
2025-05-22 14:21:52 -04:00
logger . error (
f "[ { tool_name } ] Gmail API error creating draft: { e } " , exc_info = True
)
return types . CallToolResult (
isError = True ,
content = [ types . TextContent ( type = "text" , text = f "Gmail API error: { e } " )],
)
2025-05-24 00:08:19 +08:00
except Exception as e :
logger . exception ( f "[ { tool_name } ] Unexpected error creating Gmail draft: { e } " )
return types . CallToolResult (
isError = True ,
content = [ types . TextContent ( type = "text" , text = f "Unexpected error: { e } " )],
)
@server.tool ()
async def get_gmail_thread_content (
thread_id : str ,
2025-05-24 10:43:55 -04:00
user_google_email : str ,
2025-05-24 00:08:19 +08:00
) -> types . CallToolResult :
"""
Retrieves the complete content of a Gmail conversation thread, including all messages.
Args:
thread_id (str): The unique ID of the Gmail thread to retrieve.
2025-05-24 10:43:55 -04:00
user_google_email (str): The user's Google email address. Required.
2025-05-24 00:08:19 +08:00
Returns:
types.CallToolResult: Contains the complete thread content with all messages or an error/auth guidance message.
"""
tool_name = "get_gmail_thread_content"
logger . info (
2025-05-24 10:43:55 -04:00
f "[ { tool_name } ] Invoked. Thread ID: ' { thread_id } ', Email: ' { user_google_email } '"
2025-05-24 00:08:19 +08:00
)
2025-05-24 10:43:55 -04:00
auth_result = await get_authenticated_google_service (
service_name = "gmail" ,
version = "v1" ,
tool_name = tool_name ,
2025-05-24 00:08:19 +08:00
user_google_email = user_google_email ,
required_scopes = [ GMAIL_READONLY_SCOPE ],
)
2025-05-24 10:43:55 -04:00
if isinstance ( auth_result , types . CallToolResult ):
return auth_result # Auth error
service , user_email = auth_result
2025-05-24 00:08:19 +08:00
try :
# Fetch the complete thread with all messages
thread_response = await asyncio . to_thread (
service . users ()
. threads ()
. get ( userId = "me" , id = thread_id , format = "full" )
. execute
)
messages = thread_response . get ( "messages" , [])
if not messages :
return types . CallToolResult (
content = [
types . TextContent (
type = "text" , text = f "No messages found in thread ' { thread_id } '."
)
]
)
# Extract thread subject from the first message
first_message = messages [ 0 ]
first_headers = {
h [ "name" ]: h [ "value" ]
for h in first_message . get ( "payload" , {}) . get ( "headers" , [])
}
thread_subject = first_headers . get ( "Subject" , "(no subject)" )
# Build the thread content
content_lines = [
f "Thread ID: { thread_id } " ,
f "Subject: { thread_subject } " ,
f "Messages: { len ( messages ) } " ,
"" ,
]
# Process each message in the thread
for i , message in enumerate ( messages , 1 ):
# Extract headers
headers = {
h [ "name" ]: h [ "value" ]
for h in message . get ( "payload" , {}) . get ( "headers" , [])
}
sender = headers . get ( "From" , "(unknown sender)" )
date = headers . get ( "Date" , "(unknown date)" )
subject = headers . get ( "Subject" , "(no subject)" )
# Extract message body
payload = message . get ( "payload" , {})
body_data = _extract_message_body ( payload )
# Add message to content
content_lines . extend (
[
f "=== Message { i } ===" ,
f "From: { sender } " ,
f "Date: { date } " ,
]
)
# Only show subject if it's different from thread subject
if subject != thread_subject :
content_lines . append ( f "Subject: { subject } " )
content_lines . extend (
[
"" ,
body_data or "[No text/plain body found]" ,
"" ,
]
)
content_text = " \n " . join ( content_lines )
return types . CallToolResult (
content = [ types . TextContent ( type = "text" , text = content_text )]
)
except HttpError as e :
logger . error (
f "[ { tool_name } ] Gmail API error getting thread content: { e } " , exc_info = True
)
return types . CallToolResult (
isError = True ,
content = [ types . TextContent ( type = "text" , text = f "Gmail API error: { e } " )],
)
2025-05-22 00:02:51 +05:30
except Exception as e :
2025-05-22 14:21:52 -04:00
logger . exception (
2025-05-24 00:08:19 +08:00
f "[ { tool_name } ] Unexpected error getting Gmail thread content: { e } "
2025-05-22 14:21:52 -04:00
)
return types . CallToolResult (
isError = True ,
content = [ types . TextContent ( type = "text" , text = f "Unexpected error: { e } " )],
)