Add tool call logging and reply fields
All checks were successful
Build And Test / publish (push) Successful in 49s

This commit is contained in:
2026-01-01 15:24:06 -08:00
parent 7966a4302d
commit 767f076048
7 changed files with 335 additions and 117 deletions

View File

@@ -145,11 +145,32 @@ Add your MCP endpoint at https://poke.com/settings/connections.
| Category | Tools | | Category | Tools |
| --- | --- | | --- | --- |
| Email | `list_mailboxes`, `list_emails`, `list_drafts`, `read_email`, `search_emails`, `move_email`, `delete_email`, `delete_draft`, `save_draft`, `edit_draft`, `send_email`, `reply_email`, `set_email_flags`, `unsubscribe_email` | | Email | `list_mailboxes`, `list_emails`, `list_drafts`, `read_email`, `search_emails`, `move_email`, `delete_email`, `delete_draft`, `save_draft`, `edit_draft`, `send_email`, `set_email_flags`, `unsubscribe_email` |
| Calendar | `list_calendars`, `list_events`, `get_event`, `create_event`, `update_event`, `delete_event` | | Calendar | `list_calendars`, `list_events`, `get_event`, `create_event`, `update_event`, `delete_event` |
| Contacts | `list_addressbooks`, `list_contacts`, `get_contact`, `create_contact`, `update_contact`, `delete_contact` | | Contacts | `list_addressbooks`, `list_contacts`, `get_contact`, `create_contact`, `update_contact`, `delete_contact` |
| System | `get_server_info` | | System | `get_server_info` |
### Replying to an email
Use `reply_to_email_id` on `save_draft`, `edit_draft`, or `send_email` to create a reply without a separate tool.
- Provide `reply_to_email_id` (and optionally `reply_mailbox`, default `INBOX`).
- `reply_all=true` includes original recipients; otherwise it replies to the sender/Reply-To.
- If `to`/`subject` are omitted, they are derived from the original email; `body` is still required.
Example (send a reply):
```json
{
"tool": "send_email",
"args": {
"reply_to_email_id": "12345",
"reply_mailbox": "INBOX",
"reply_all": true,
"body": "Thanks — sounds good to me."
}
}
```
## Database and Migrations ## Database and Migrations
The server uses SQLite (default: `/data/cache.db`) and Alembic. The server uses SQLite (default: `/data/cache.db`) and Alembic.

View File

@@ -19,6 +19,7 @@ from fastmcp import FastMCP
from config import settings from config import settings
from database import init_db, close_db from database import init_db, close_db
from tools.logging_utils import log_tool_call
# Configure logging # Configure logging
logging.basicConfig( logging.basicConfig(
@@ -104,6 +105,7 @@ def register_tools():
# Server info tool (always available) # Server info tool (always available)
@mcp.tool(description="Get information about this PIM MCP server including enabled services and version.") @mcp.tool(description="Get information about this PIM MCP server including enabled services and version.")
@log_tool_call
def get_server_info() -> dict: def get_server_info() -> dict:
"""Get server information and status.""" """Get server information and status."""
return { return {

View File

@@ -433,17 +433,49 @@ class EmailService:
def save_draft( def save_draft(
self, self,
to: list[str], to: Optional[list[str]] = None,
subject: str, subject: Optional[str] = None,
body: str, body: Optional[str] = None,
cc: Optional[list[str]] = None, cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None, bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None, reply_to: Optional[str] = None,
html_body: Optional[str] = None, html_body: Optional[str] = None,
mailbox: Optional[str] = None, mailbox: Optional[str] = None,
reply_to_email_id: Optional[str] = None,
reply_mailbox: Optional[str] = None,
reply_all: bool = False,
) -> OperationResult: ) -> OperationResult:
try: try:
draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts" draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts"
if reply_to_email_id:
context, error = self._get_reply_context(
reply_mailbox or "INBOX",
reply_to_email_id,
reply_all,
cc,
self.settings.smtp_from_email,
)
if error:
return error
if not to:
to = context["to"]
if subject is None:
subject = context["subject"]
if cc is None:
cc = context["cc"]
in_reply_to = context["in_reply_to"]
references = context["references"]
else:
in_reply_to = None
references = None
if not to:
return OperationResult(success=False, message="'to' is required for drafts")
if subject is None:
return OperationResult(success=False, message="'subject' is required for drafts")
if body is None:
return OperationResult(success=False, message="'body' is required for drafts")
msg = self._build_draft_message( msg = self._build_draft_message(
to=to, to=to,
subject=subject, subject=subject,
@@ -452,6 +484,8 @@ class EmailService:
bcc=bcc, bcc=bcc,
reply_to=reply_to, reply_to=reply_to,
html_body=html_body, html_body=html_body,
in_reply_to=in_reply_to,
references=references,
) )
client = self._get_imap_client() client = self._get_imap_client()
append_result = client.append( append_result = client.append(
@@ -481,6 +515,9 @@ class EmailService:
bcc: Optional[list[str]] = None, bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None, reply_to: Optional[str] = None,
html_body: Optional[str] = None, html_body: Optional[str] = None,
reply_to_email_id: Optional[str] = None,
reply_mailbox: Optional[str] = None,
reply_all: bool = False,
) -> OperationResult: ) -> OperationResult:
draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts" draft_mailbox = mailbox or self._find_drafts_folder() or "Drafts"
try: try:
@@ -502,6 +539,28 @@ class EmailService:
resolved_body = body if body is not None else (existing.body_text or "") resolved_body = body if body is not None else (existing.body_text or "")
resolved_html = html_body if html_body is not None else existing.body_html resolved_html = html_body if html_body is not None else existing.body_html
if reply_to_email_id:
context, error = self._get_reply_context(
reply_mailbox or "INBOX",
reply_to_email_id,
reply_all,
cc,
self.settings.smtp_from_email,
)
if error:
return error
if to is None:
resolved_to = context["to"]
if subject is None:
resolved_subject = context["subject"]
if cc is None:
resolved_cc = context["cc"]
in_reply_to = context["in_reply_to"]
references = context["references"]
else:
in_reply_to = None
references = None
try: try:
client = self._get_imap_client() client = self._get_imap_client()
client.select_folder(draft_mailbox) client.select_folder(draft_mailbox)
@@ -517,6 +576,8 @@ class EmailService:
bcc=resolved_bcc, bcc=resolved_bcc,
reply_to=reply_to, reply_to=reply_to,
html_body=resolved_html, html_body=resolved_html,
in_reply_to=in_reply_to,
references=references,
) )
append_result = client.append( append_result = client.append(
draft_mailbox, draft_mailbox,
@@ -536,9 +597,9 @@ class EmailService:
async def send_email( async def send_email(
self, self,
to: list[str], to: Optional[list[str]] = None,
subject: str, subject: Optional[str] = None,
body: str, body: Optional[str] = None,
cc: Optional[list[str]] = None, cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None, bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None, reply_to: Optional[str] = None,
@@ -547,8 +608,38 @@ class EmailService:
sender_name: Optional[str] = None, sender_name: Optional[str] = None,
in_reply_to: Optional[str] = None, in_reply_to: Optional[str] = None,
references: Optional[list[str]] = None, references: Optional[list[str]] = None,
reply_to_email_id: Optional[str] = None,
reply_mailbox: Optional[str] = None,
reply_all: bool = False,
) -> OperationResult: ) -> OperationResult:
try: try:
if reply_to_email_id:
resolved_name, resolved_email = self._resolve_sender(sender_email, sender_name)
context, error = self._get_reply_context(
reply_mailbox or "INBOX",
reply_to_email_id,
reply_all,
cc,
resolved_email,
)
if error:
return error
if not to:
to = context["to"]
if subject is None:
subject = context["subject"]
if cc is None:
cc = context["cc"]
in_reply_to = context["in_reply_to"]
references = context["references"]
if not to:
return OperationResult(success=False, message="'to' is required to send email")
if subject is None:
return OperationResult(success=False, message="'subject' is required to send email")
if body is None:
return OperationResult(success=False, message="'body' is required to send email")
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg["Subject"] = subject msg["Subject"] = subject
resolved_name, resolved_email = self._resolve_sender(sender_email, sender_name) resolved_name, resolved_email = self._resolve_sender(sender_email, sender_name)
@@ -609,67 +700,17 @@ class EmailService:
sender_email: Optional[str] = None, sender_email: Optional[str] = None,
sender_name: Optional[str] = None, sender_name: Optional[str] = None,
) -> OperationResult: ) -> OperationResult:
original = self.read_email(mailbox, email_id, format="both")
if not original:
return OperationResult(
success=False,
message=f"Email {email_id} not found in {mailbox}",
id=email_id,
)
resolved_name, resolved_email = self._resolve_sender(sender_email, sender_name)
reply_to_header = original.headers.get("Reply-To")
reply_to_email = None
if reply_to_header:
_, reply_to_email = parseaddr(reply_to_header)
if not reply_to_email:
reply_to_email = original.from_address.email
to = [reply_to_email] if reply_to_email else []
reply_cc: list[str] = []
if reply_all:
for addr in original.to_addresses + original.cc_addresses:
if addr.email and addr.email not in to:
reply_cc.append(addr.email)
if cc:
reply_cc.extend(cc)
to = self._dedupe_emails(to, resolved_email)
reply_cc = self._dedupe_emails(reply_cc, resolved_email)
if not to and reply_cc:
to = [reply_cc.pop(0)]
if not to:
return OperationResult(
success=False,
message="No valid recipients found for reply",
id=email_id,
)
subject = original.subject or "(No Subject)"
if not subject.lower().startswith("re:"):
subject = f"Re: {subject}"
in_reply_to = original.headers.get("Message-ID") or original.in_reply_to
references = list(original.references)
if in_reply_to and in_reply_to not in references:
references.append(in_reply_to)
return await self.send_email( return await self.send_email(
to=to,
subject=subject,
body=body, body=body,
cc=reply_cc or None,
bcc=bcc, bcc=bcc,
reply_to=reply_to, reply_to=reply_to,
html_body=html_body, html_body=html_body,
sender_email=resolved_email, sender_email=sender_email,
sender_name=resolved_name, sender_name=sender_name,
in_reply_to=in_reply_to, reply_to_email_id=email_id,
references=references or None, reply_mailbox=mailbox,
reply_all=reply_all,
cc=cc,
) )
def _parse_envelope_addresses(self, addresses) -> list[EmailAddress]: def _parse_envelope_addresses(self, addresses) -> list[EmailAddress]:
@@ -805,6 +846,8 @@ class EmailService:
bcc: Optional[list[str]] = None, bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None, reply_to: Optional[str] = None,
html_body: Optional[str] = None, html_body: Optional[str] = None,
in_reply_to: Optional[str] = None,
references: Optional[list[str]] = None,
) -> MIMEMultipart: ) -> MIMEMultipart:
msg = MIMEMultipart("alternative") msg = MIMEMultipart("alternative")
msg["Subject"] = subject msg["Subject"] = subject
@@ -821,12 +864,80 @@ class EmailService:
msg["Bcc"] = ", ".join(bcc) msg["Bcc"] = ", ".join(bcc)
if reply_to: if reply_to:
msg["Reply-To"] = reply_to msg["Reply-To"] = reply_to
if in_reply_to:
msg["In-Reply-To"] = in_reply_to
if references:
msg["References"] = " ".join(references)
msg.attach(MIMEText(body or "", "plain", "utf-8")) msg.attach(MIMEText(body or "", "plain", "utf-8"))
if html_body: if html_body:
msg.attach(MIMEText(html_body, "html", "utf-8")) msg.attach(MIMEText(html_body, "html", "utf-8"))
return msg return msg
def _get_reply_context(
self,
mailbox: str,
email_id: str,
reply_all: bool,
cc: Optional[list[str]],
sender_email: Optional[str],
) -> tuple[dict, Optional[OperationResult]]:
original = self.read_email(mailbox, email_id, format="both")
if not original:
return {}, OperationResult(
success=False,
message=f"Email {email_id} not found in {mailbox}",
id=email_id,
)
reply_to_header = original.headers.get("Reply-To")
reply_to_email = None
if reply_to_header:
_, reply_to_email = parseaddr(reply_to_header)
if not reply_to_email:
reply_to_email = original.from_address.email
to = [reply_to_email] if reply_to_email else []
reply_cc: list[str] = []
if reply_all:
for addr in original.to_addresses + original.cc_addresses:
if addr.email and addr.email not in to:
reply_cc.append(addr.email)
if cc:
reply_cc.extend(cc)
to = self._dedupe_emails(to, sender_email)
reply_cc = self._dedupe_emails(reply_cc, sender_email)
if not to and reply_cc:
to = [reply_cc.pop(0)]
if not to:
return {}, OperationResult(
success=False,
message="No valid recipients found for reply",
id=email_id,
)
subject = original.subject or "(No Subject)"
if not subject.lower().startswith("re:"):
subject = f"Re: {subject}"
in_reply_to = original.headers.get("Message-ID") or original.in_reply_to
references = list(original.references)
if in_reply_to and in_reply_to not in references:
references.append(in_reply_to)
return {
"to": to,
"cc": reply_cc or None,
"subject": subject,
"in_reply_to": in_reply_to,
"references": references or None,
}, None
def _resolve_sender( def _resolve_sender(
self, sender_email: Optional[str], sender_name: Optional[str] self, sender_email: Optional[str], sender_name: Optional[str]
) -> tuple[Optional[str], str]: ) -> tuple[Optional[str], str]:

View File

@@ -2,18 +2,21 @@ from typing import Optional
from fastmcp import FastMCP from fastmcp import FastMCP
from services.calendar_service import CalendarService from services.calendar_service import CalendarService
from tools.logging_utils import log_tool_call
def register_calendar_tools(mcp: FastMCP, service: CalendarService): def register_calendar_tools(mcp: FastMCP, service: CalendarService):
"""Register all calendar-related MCP tools.""" """Register all calendar-related MCP tools."""
@mcp.tool(description="List all available calendars from CalDAV and configured ICS feeds. Returns calendar ID, name, and properties.") @mcp.tool(description="List all available calendars from CalDAV and configured ICS feeds. Returns calendar ID, name, and properties.")
@log_tool_call
def list_calendars() -> list[dict]: def list_calendars() -> list[dict]:
"""List all calendars.""" """List all calendars."""
calendars = service.list_calendars() calendars = service.list_calendars()
return [c.model_dump() for c in calendars] return [c.model_dump() for c in calendars]
@mcp.tool(description="List events within a date range. If no calendar is specified, lists events from all calendars.") @mcp.tool(description="List events within a date range. If no calendar is specified, lists events from all calendars.")
@log_tool_call
def list_events( def list_events(
start_date: str, start_date: str,
end_date: str, end_date: str,
@@ -59,6 +62,7 @@ def register_calendar_tools(mcp: FastMCP, service: CalendarService):
} }
@mcp.tool(description="Get detailed information about a specific calendar event including attendees and recurrence.") @mcp.tool(description="Get detailed information about a specific calendar event including attendees and recurrence.")
@log_tool_call
def get_event( def get_event(
calendar_id: str, calendar_id: str,
event_id: str, event_id: str,
@@ -74,6 +78,7 @@ def register_calendar_tools(mcp: FastMCP, service: CalendarService):
return result.model_dump() if result else None return result.model_dump() if result else None
@mcp.tool(description="Create a new calendar event with title, time, location, attendees, and optional recurrence.") @mcp.tool(description="Create a new calendar event with title, time, location, attendees, and optional recurrence.")
@log_tool_call
def create_event( def create_event(
calendar_id: str, calendar_id: str,
title: str, title: str,
@@ -105,6 +110,7 @@ def register_calendar_tools(mcp: FastMCP, service: CalendarService):
return result.model_dump() return result.model_dump()
@mcp.tool(description="Update an existing calendar event. Only provided fields will be modified.") @mcp.tool(description="Update an existing calendar event. Only provided fields will be modified.")
@log_tool_call
def update_event( def update_event(
calendar_id: str, calendar_id: str,
event_id: str, event_id: str,
@@ -134,6 +140,7 @@ def register_calendar_tools(mcp: FastMCP, service: CalendarService):
return result.model_dump() if result else None return result.model_dump() if result else None
@mcp.tool(description="Delete a calendar event by ID.") @mcp.tool(description="Delete a calendar event by ID.")
@log_tool_call
def delete_event( def delete_event(
calendar_id: str, calendar_id: str,
event_id: str, event_id: str,

View File

@@ -2,18 +2,21 @@ from typing import Optional
from fastmcp import FastMCP from fastmcp import FastMCP
from services.contacts_service import ContactsService from services.contacts_service import ContactsService
from tools.logging_utils import log_tool_call
def register_contacts_tools(mcp: FastMCP, service: ContactsService): def register_contacts_tools(mcp: FastMCP, service: ContactsService):
"""Register all contacts-related MCP tools.""" """Register all contacts-related MCP tools."""
@mcp.tool(description="List all available address books from the CardDAV server.") @mcp.tool(description="List all available address books from the CardDAV server.")
@log_tool_call
def list_addressbooks() -> list[dict]: def list_addressbooks() -> list[dict]:
"""List all address books.""" """List all address books."""
addressbooks = service.list_addressbooks() addressbooks = service.list_addressbooks()
return [a.model_dump() for a in addressbooks] return [a.model_dump() for a in addressbooks]
@mcp.tool(description="List contacts in an address book with optional search filtering and pagination.") @mcp.tool(description="List contacts in an address book with optional search filtering and pagination.")
@log_tool_call
def list_contacts( def list_contacts(
addressbook_id: str, addressbook_id: str,
search: Optional[str] = None, search: Optional[str] = None,
@@ -33,6 +36,7 @@ def register_contacts_tools(mcp: FastMCP, service: ContactsService):
return result.model_dump() return result.model_dump()
@mcp.tool(description="Get detailed information about a specific contact including all fields.") @mcp.tool(description="Get detailed information about a specific contact including all fields.")
@log_tool_call
def get_contact( def get_contact(
addressbook_id: str, addressbook_id: str,
contact_id: str, contact_id: str,
@@ -48,6 +52,7 @@ def register_contacts_tools(mcp: FastMCP, service: ContactsService):
return result.model_dump() if result else None return result.model_dump() if result else None
@mcp.tool(description="Create a new contact with name, emails, phones, addresses, and other details.") @mcp.tool(description="Create a new contact with name, emails, phones, addresses, and other details.")
@log_tool_call
def create_contact( def create_contact(
addressbook_id: str, addressbook_id: str,
first_name: Optional[str] = None, first_name: Optional[str] = None,
@@ -93,6 +98,7 @@ def register_contacts_tools(mcp: FastMCP, service: ContactsService):
return result.model_dump() return result.model_dump()
@mcp.tool(description="Update an existing contact. Only provided fields will be modified.") @mcp.tool(description="Update an existing contact. Only provided fields will be modified.")
@log_tool_call
def update_contact( def update_contact(
addressbook_id: str, addressbook_id: str,
contact_id: str, contact_id: str,
@@ -138,6 +144,7 @@ def register_contacts_tools(mcp: FastMCP, service: ContactsService):
return result.model_dump() if result else None return result.model_dump() if result else None
@mcp.tool(description="Delete a contact from an address book.") @mcp.tool(description="Delete a contact from an address book.")
@log_tool_call
def delete_contact( def delete_contact(
addressbook_id: str, addressbook_id: str,
contact_id: str, contact_id: str,

View File

@@ -2,18 +2,21 @@ from typing import Optional
from fastmcp import FastMCP from fastmcp import FastMCP
from services.email_service import EmailService from services.email_service import EmailService
from tools.logging_utils import log_tool_call
def register_email_tools(mcp: FastMCP, service: EmailService): def register_email_tools(mcp: FastMCP, service: EmailService):
"""Register all email-related MCP tools.""" """Register all email-related MCP tools."""
@mcp.tool(description="List all mailboxes/folders in the email account. Returns name, path, message count, and unread count for each mailbox.") @mcp.tool(description="List all mailboxes/folders in the email account. Returns name, path, message count, and unread count for each mailbox.")
@log_tool_call
def list_mailboxes() -> list[dict]: def list_mailboxes() -> list[dict]:
"""List all IMAP mailboxes/folders.""" """List all IMAP mailboxes/folders."""
mailboxes = service.list_mailboxes() mailboxes = service.list_mailboxes()
return [m.model_dump() for m in mailboxes] return [m.model_dump() for m in mailboxes]
@mcp.tool(description="List emails in a mailbox with pagination. Returns email summaries including subject, from, date, and read status.") @mcp.tool(description="List emails in a mailbox with pagination. Returns email summaries including subject, from, date, and read status.")
@log_tool_call
def list_emails( def list_emails(
mailbox: str = "INBOX", mailbox: str = "INBOX",
limit: int = 50, limit: int = 50,
@@ -33,6 +36,7 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
return result.model_dump() return result.model_dump()
@mcp.tool(description="List draft emails in the Drafts mailbox with pagination.") @mcp.tool(description="List draft emails in the Drafts mailbox with pagination.")
@log_tool_call
def list_drafts( def list_drafts(
mailbox: Optional[str] = None, mailbox: Optional[str] = None,
limit: int = 50, limit: int = 50,
@@ -52,6 +56,7 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
return result.model_dump() return result.model_dump()
@mcp.tool(description="Read a specific email by ID with full body content and attachment information.") @mcp.tool(description="Read a specific email by ID with full body content and attachment information.")
@log_tool_call
def read_email( def read_email(
mailbox: str, mailbox: str,
email_id: str, email_id: str,
@@ -69,6 +74,7 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
return result.model_dump() if result else None return result.model_dump() if result else None
@mcp.tool(description="Search emails in a mailbox using various criteria like subject, sender, or body content.") @mcp.tool(description="Search emails in a mailbox using various criteria like subject, sender, or body content.")
@log_tool_call
def search_emails( def search_emails(
query: str, query: str,
mailbox: str = "INBOX", mailbox: str = "INBOX",
@@ -94,6 +100,7 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
return result.model_dump() return result.model_dump()
@mcp.tool(description="Move an email from one mailbox/folder to another.") @mcp.tool(description="Move an email from one mailbox/folder to another.")
@log_tool_call
def move_email( def move_email(
email_id: str, email_id: str,
source_mailbox: str, source_mailbox: str,
@@ -111,6 +118,7 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
return result.model_dump() return result.model_dump()
@mcp.tool(description="Delete an email, either moving it to trash or permanently deleting it.") @mcp.tool(description="Delete an email, either moving it to trash or permanently deleting it.")
@log_tool_call
def delete_email( def delete_email(
email_id: str, email_id: str,
mailbox: str, mailbox: str,
@@ -128,6 +136,7 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
return result.model_dump() return result.model_dump()
@mcp.tool(description="Delete a drafted email by ID, optionally permanently.") @mcp.tool(description="Delete a drafted email by ID, optionally permanently.")
@log_tool_call
def delete_draft( def delete_draft(
email_id: str, email_id: str,
mailbox: Optional[str] = None, mailbox: Optional[str] = None,
@@ -145,33 +154,53 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
return result.model_dump() return result.model_dump()
@mcp.tool(description="Save a new draft email to the Drafts mailbox.") @mcp.tool(description="Save a new draft email to the Drafts mailbox.")
@log_tool_call
def save_draft( def save_draft(
to: list[str], to: Optional[list[str]] = None,
subject: str, subject: Optional[str] = None,
body: str, body: Optional[str] = None,
cc: Optional[list[str]] = None, cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None, bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None, reply_to: Optional[str] = None,
html_body: Optional[str] = None, html_body: Optional[str] = None,
mailbox: Optional[str] = None, mailbox: Optional[str] = None,
reply_to_email_id: Optional[str] = None,
reply_mailbox: Optional[str] = None,
reply_all: bool = False,
) -> dict: ) -> dict:
""" """
Save a new email draft. Save a new email draft.
Args: Args:
to: List of recipient email addresses to: List of recipient email addresses (required unless reply_to_email_id is set)
subject: Email subject line subject: Email subject line (required unless reply_to_email_id is set)
body: Plain text email body body: Plain text email body (required unless reply_to_email_id is set)
cc: List of CC recipients (optional) cc: List of CC recipients (optional)
bcc: List of BCC recipients (optional) bcc: List of BCC recipients (optional)
reply_to: Reply-to address (optional) reply_to: Reply-to address (optional)
html_body: HTML version of the email body (optional) html_body: HTML version of the email body (optional)
mailbox: Drafts mailbox/folder override (default: auto-detect) mailbox: Drafts mailbox/folder override (default: auto-detect)
reply_to_email_id: Email ID to reply to (optional)
reply_mailbox: Mailbox containing the reply_to_email_id (default: INBOX)
reply_all: Whether to include original recipients when replying (default: False)
""" """
result = service.save_draft(to, subject, body, cc, bcc, reply_to, html_body, mailbox) result = service.save_draft(
to,
subject,
body,
cc,
bcc,
reply_to,
html_body,
mailbox,
reply_to_email_id,
reply_mailbox,
reply_all,
)
return result.model_dump() return result.model_dump()
@mcp.tool(description="Edit an existing draft email. Only provided fields will be modified.") @mcp.tool(description="Edit an existing draft email. Only provided fields will be modified.")
@log_tool_call
def edit_draft( def edit_draft(
email_id: str, email_id: str,
mailbox: Optional[str] = None, mailbox: Optional[str] = None,
@@ -182,6 +211,9 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
bcc: Optional[list[str]] = None, bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None, reply_to: Optional[str] = None,
html_body: Optional[str] = None, html_body: Optional[str] = None,
reply_to_email_id: Optional[str] = None,
reply_mailbox: Optional[str] = None,
reply_all: bool = False,
) -> dict: ) -> dict:
""" """
Update an existing draft email. Update an existing draft email.
@@ -196,6 +228,9 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
bcc: List of BCC recipients (optional) bcc: List of BCC recipients (optional)
reply_to: Reply-to address (optional) reply_to: Reply-to address (optional)
html_body: HTML version of the email body (optional) html_body: HTML version of the email body (optional)
reply_to_email_id: Email ID to reply to (optional)
reply_mailbox: Mailbox containing the reply_to_email_id (default: INBOX)
reply_all: Whether to include original recipients when replying (default: False)
""" """
result = service.update_draft( result = service.update_draft(
email_id, email_id,
@@ -207,34 +242,44 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
bcc, bcc,
reply_to, reply_to,
html_body, html_body,
reply_to_email_id,
reply_mailbox,
reply_all,
) )
return result.model_dump() return result.model_dump()
@mcp.tool(description="Send a new email via SMTP. Supports plain text and HTML content, CC, BCC, reply-to, and custom sender.") @mcp.tool(description="Send a new email via SMTP. Supports plain text and HTML content, CC, BCC, reply-to, and custom sender.")
@log_tool_call
async def send_email( async def send_email(
to: list[str], to: Optional[list[str]] = None,
subject: str, subject: Optional[str] = None,
body: str, body: Optional[str] = None,
cc: Optional[list[str]] = None, cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None, bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None, reply_to: Optional[str] = None,
html_body: Optional[str] = None, html_body: Optional[str] = None,
sender_email: Optional[str] = None, sender_email: Optional[str] = None,
sender_name: Optional[str] = None, sender_name: Optional[str] = None,
reply_to_email_id: Optional[str] = None,
reply_mailbox: Optional[str] = None,
reply_all: bool = False,
) -> dict: ) -> dict:
""" """
Send a new email. Send a new email.
Args: Args:
to: List of recipient email addresses to: List of recipient email addresses (required unless reply_to_email_id is set)
subject: Email subject line subject: Email subject line (required unless reply_to_email_id is set)
body: Plain text email body body: Plain text email body (required unless reply_to_email_id is set)
cc: List of CC recipients (optional) cc: List of CC recipients (optional)
bcc: List of BCC recipients (optional) bcc: List of BCC recipients (optional)
reply_to: Reply-to address (optional) reply_to: Reply-to address (optional)
html_body: HTML version of the email body (optional) html_body: HTML version of the email body (optional)
sender_email: Sender email address (optional, defaults to SMTP_FROM_EMAIL) sender_email: Sender email address (optional, defaults to SMTP_FROM_EMAIL)
sender_name: Sender display name (optional, defaults to SMTP_FROM_NAME) sender_name: Sender display name (optional, defaults to SMTP_FROM_NAME)
reply_to_email_id: Email ID to reply to (optional)
reply_mailbox: Mailbox containing the reply_to_email_id (default: INBOX)
reply_all: Whether to include original recipients when replying (default: False)
""" """
result = await service.send_email( result = await service.send_email(
to, to,
@@ -246,52 +291,16 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
html_body, html_body,
sender_email, sender_email,
sender_name, sender_name,
) None,
return result.model_dump() None,
reply_to_email_id,
@mcp.tool(description="Reply to an existing email by ID, with optional reply-all behavior.") reply_mailbox,
async def reply_email(
email_id: str,
body: str,
mailbox: str = "INBOX",
reply_all: bool = False,
cc: Optional[list[str]] = None,
bcc: Optional[list[str]] = None,
reply_to: Optional[str] = None,
html_body: Optional[str] = None,
sender_email: Optional[str] = None,
sender_name: Optional[str] = None,
) -> dict:
"""
Reply to an existing email.
Args:
email_id: The unique ID of the email to reply to
body: Plain text email body
mailbox: The mailbox containing the email (default: INBOX)
reply_all: Whether to include original recipients (default: False)
cc: List of CC recipients (optional)
bcc: List of BCC recipients (optional)
reply_to: Reply-to address for the reply (optional)
html_body: HTML version of the email body (optional)
sender_email: Sender email address (optional, defaults to SMTP_FROM_EMAIL)
sender_name: Sender display name (optional, defaults to SMTP_FROM_NAME)
"""
result = await service.reply_email(
mailbox,
email_id,
body,
reply_all, reply_all,
cc,
bcc,
reply_to,
html_body,
sender_email,
sender_name,
) )
return result.model_dump() return result.model_dump()
@mcp.tool(description="Set or remove IMAP flags on an email. Standard flags: \\Seen, \\Answered, \\Flagged, \\Deleted, \\Draft. Custom keywords are also supported.") @mcp.tool(description="Set or remove IMAP flags on an email. Standard flags: \\Seen, \\Answered, \\Flagged, \\Deleted, \\Draft. Custom keywords are also supported.")
@log_tool_call
def set_email_flags( def set_email_flags(
email_id: str, email_id: str,
mailbox: str, mailbox: str,
@@ -311,6 +320,7 @@ def register_email_tools(mcp: FastMCP, service: EmailService):
return result.model_dump() return result.model_dump()
@mcp.tool(description="Unsubscribe from a mailing list. Parses List-Unsubscribe headers and attempts automatic unsubscribe via HTTP or provides mailto instructions.") @mcp.tool(description="Unsubscribe from a mailing list. Parses List-Unsubscribe headers and attempts automatic unsubscribe via HTTP or provides mailto instructions.")
@log_tool_call
async def unsubscribe_email( async def unsubscribe_email(
email_id: str, email_id: str,
mailbox: str = "INBOX", mailbox: str = "INBOX",

View File

@@ -0,0 +1,60 @@
import functools
import inspect
import json
import logging
import os
logger = logging.getLogger("mcp.tools")
_MAX_LOG_CHARS = int(os.getenv("TOOL_LOG_MAX_CHARS", "4000"))
def _serialize(value) -> str:
try:
return json.dumps(value, default=str, ensure_ascii=True)
except TypeError:
return repr(value)
def _truncate(text: str) -> str:
if len(text) <= _MAX_LOG_CHARS:
return text
truncated = len(text) - _MAX_LOG_CHARS
return f"{text[:_MAX_LOG_CHARS]}...(truncated {truncated} chars)"
def log_tool_call(func):
"""Log tool calls and responses with optional truncation."""
if inspect.iscoroutinefunction(func):
@functools.wraps(func)
async def wrapper(*args, **kwargs):
logger.info(
"Tool call %s args=%s kwargs=%s",
func.__name__,
_truncate(_serialize(args)),
_truncate(_serialize(kwargs)),
)
result = await func(*args, **kwargs)
logger.info(
"Tool response %s result=%s",
func.__name__,
_truncate(_serialize(result)),
)
return result
return wrapper
@functools.wraps(func)
def wrapper(*args, **kwargs):
logger.info(
"Tool call %s args=%s kwargs=%s",
func.__name__,
_truncate(_serialize(args)),
_truncate(_serialize(kwargs)),
)
result = func(*args, **kwargs)
logger.info(
"Tool response %s result=%s",
func.__name__,
_truncate(_serialize(result)),
)
return result
return wrapper