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>
102 lines
3.1 KiB
Python
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()
|