dotfiles/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/server.py
DannyDannyDanny af9f735abc feat(phantom-ship): hara-gmail-mcp server (path 1, IMAP+SMTP) 📬
Adds an MCP server exposing read tools (list_inbox, search, read_email)
across three personal Gmail accounts using existing app passwords in
/etc/openclaw/. Wired into claude-channels via --mcp-config. Slated for
replacement by an OAuth2 Gmail+Calendar server in path 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 14:15:10 +02:00

102 lines
3.1 KiB
Python

"""Hara Gmail MCP server.
Exposes a small toolset for reading and (later) replying to mail across
the configured Gmail accounts. v1 ships read-only tools; reply/archive/label
follow once Hara is using these reliably.
Tools:
list_accounts() list configured accounts
list_inbox(email, limit) recent messages from an account
search(email, query, limit) IMAP SEARCH wrapper
read_email(email, uid) full body of one message
"""
from __future__ import annotations
import json
import logging
import os
import sys
from dataclasses import asdict
from mcp.server.fastmcp import FastMCP
from .accounts import AccountStore
from .imap_client import list_inbox, read_email, search
logger = logging.getLogger("hara_gmail_mcp")
mcp = FastMCP("hara-gmail-mcp")
_store: AccountStore | None = None
def _get_store() -> AccountStore:
global _store
if _store is None:
_store = AccountStore.from_config_file()
return _store
@mcp.tool()
def list_accounts() -> list[str]:
"""Return the email addresses of all Gmail accounts Hara can access."""
return _get_store().emails()
@mcp.tool()
def gmail_list_inbox(email: str, limit: int = 20) -> str:
"""List the most recent messages in INBOX for the given account.
Args:
email: which configured account to read (use list_accounts to see options)
limit: max number of messages to return, newest first (default 20, cap 100)
Returns:
JSON list of {uid, subject, sender, date, flags}.
"""
limit = max(1, min(int(limit), 100))
msgs = list_inbox(_get_store(), email, limit=limit)
return json.dumps([asdict(m) for m in msgs], ensure_ascii=False)
@mcp.tool()
def gmail_search(email: str, query: str, limit: int = 20) -> str:
"""Run an IMAP SEARCH against the given account's INBOX.
Args:
email: which configured account to search
query: raw IMAP search expression, e.g. 'UNSEEN', 'FROM alice@x.com',
'SUBJECT "invoice"', 'SINCE 1-Jan-2026'. Quote arguments as needed.
limit: max results (default 20, cap 100)
Returns:
JSON list of {uid, subject, sender, date, flags}.
"""
limit = max(1, min(int(limit), 100))
msgs = search(_get_store(), email, query=query, limit=limit)
return json.dumps([asdict(m) for m in msgs], ensure_ascii=False)
@mcp.tool()
def gmail_read_email(email: str, uid: str) -> str:
"""Fetch the full body of one message by IMAP UID.
Args:
email: which configured account
uid: the message UID (returned by gmail_list_inbox or gmail_search)
Returns:
JSON object with subject, sender, to, date, body_text, body_html, flags.
BODY.PEEK is used so reading does not auto-mark the message as seen.
"""
msg = read_email(_get_store(), email, uid=uid)
return json.dumps(asdict(msg), ensure_ascii=False)
def main() -> None:
logging.basicConfig(
level=os.environ.get("HARA_GMAIL_LOG_LEVEL", "INFO"),
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
stream=sys.stderr,
)
logger.info("hara-gmail-mcp starting")
mcp.run()