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>
This commit is contained in:
parent
771cc58076
commit
af9f735abc
9 changed files with 559 additions and 3 deletions
0
nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/__init__.py
Normal file
0
nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/__init__.py
Normal file
6
nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/__main__.py
Normal file
6
nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/__main__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Entry point for `python -m hara_gmail_mcp` and the `hara-gmail-mcp` script."""
|
||||
from .server import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
112
nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/accounts.py
Normal file
112
nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/accounts.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
"""Account config loader.
|
||||
|
||||
Reads a JSON file (default: /etc/hara/gmail-accounts.json) listing the Gmail
|
||||
accounts Hara can act on, and the path to each account's IMAP/SMTP app
|
||||
password. Passwords are loaded once via `sudo -n cat` because the password
|
||||
files are root:991 0640 and the MCP server process runs as `danny`. The
|
||||
result is cached in memory for the process lifetime.
|
||||
|
||||
Schema:
|
||||
{
|
||||
"accounts": [
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password_file": "/etc/openclaw/gmail-user-app-password",
|
||||
"imap_host": "imap.gmail.com",
|
||||
"imap_port": 993,
|
||||
"smtp_host": "smtp.gmail.com",
|
||||
"smtp_port": 465
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_CONFIG_PATH = "/etc/hara/gmail-accounts.json"
|
||||
# NixOS keeps the setuid sudo wrapper at /run/wrappers/bin; non-NixOS distros
|
||||
# put it in /usr/bin or /bin. We try $PATH first, then fall back to these.
|
||||
_SUDO_FALLBACKS = ["/run/wrappers/bin/sudo", "/usr/bin/sudo", "/bin/sudo"]
|
||||
|
||||
|
||||
def _find_sudo() -> str:
|
||||
found = shutil.which("sudo")
|
||||
if found:
|
||||
return found
|
||||
for candidate in _SUDO_FALLBACKS:
|
||||
if Path(candidate).exists():
|
||||
return candidate
|
||||
raise RuntimeError(
|
||||
"sudo not found on PATH or in known locations; "
|
||||
"cannot read group-restricted password files"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Account:
|
||||
email: str
|
||||
password_file: str
|
||||
imap_host: str
|
||||
imap_port: int
|
||||
smtp_host: str
|
||||
smtp_port: int
|
||||
|
||||
|
||||
class AccountStore:
|
||||
"""Holds account metadata and lazily resolves passwords on first use."""
|
||||
|
||||
def __init__(self, accounts: list[Account]) -> None:
|
||||
self._accounts = {a.email: a for a in accounts}
|
||||
self._password_cache: dict[str, str] = {}
|
||||
|
||||
@classmethod
|
||||
def from_config_file(cls, path: str | os.PathLike[str] | None = None) -> "AccountStore":
|
||||
config_path = Path(path or os.environ.get("HARA_GMAIL_CONFIG", DEFAULT_CONFIG_PATH))
|
||||
with config_path.open() as f:
|
||||
data = json.load(f)
|
||||
accounts = [
|
||||
Account(
|
||||
email=a["email"],
|
||||
password_file=a["password_file"],
|
||||
imap_host=a.get("imap_host", "imap.gmail.com"),
|
||||
imap_port=int(a.get("imap_port", 993)),
|
||||
smtp_host=a.get("smtp_host", "smtp.gmail.com"),
|
||||
smtp_port=int(a.get("smtp_port", 465)),
|
||||
)
|
||||
for a in data.get("accounts", [])
|
||||
]
|
||||
return cls(accounts)
|
||||
|
||||
def emails(self) -> list[str]:
|
||||
return list(self._accounts.keys())
|
||||
|
||||
def get(self, email: str) -> Account:
|
||||
try:
|
||||
return self._accounts[email]
|
||||
except KeyError:
|
||||
raise ValueError(f"Unknown account: {email!r}. Configured: {self.emails()}")
|
||||
|
||||
def password_for(self, email: str) -> str:
|
||||
if email in self._password_cache:
|
||||
return self._password_cache[email]
|
||||
account = self.get(email)
|
||||
# Prefer direct read if the file is reachable (e.g. after path 2
|
||||
# migration where the daemon owns its own creds), fall back to
|
||||
# `sudo -n cat` for the current /etc/openclaw/ layout.
|
||||
try:
|
||||
value = Path(account.password_file).read_text().strip()
|
||||
except PermissionError:
|
||||
value = subprocess.check_output(
|
||||
[_find_sudo(), "-n", "cat", account.password_file],
|
||||
text=True,
|
||||
).strip()
|
||||
if not value:
|
||||
raise RuntimeError(f"Empty password file for {email}: {account.password_file}")
|
||||
self._password_cache[email] = value
|
||||
return value
|
||||
184
nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/imap_client.py
Normal file
184
nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/imap_client.py
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
"""Minimal IMAP wrapper over stdlib imaplib.
|
||||
|
||||
One short-lived IMAP connection per call. Good enough for v1; if latency
|
||||
hurts when Hara fans out across three accounts in a summary, swap to a
|
||||
connection pool keyed by account email.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import email
|
||||
import email.policy
|
||||
import imaplib
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from email.message import EmailMessage
|
||||
from typing import Iterator
|
||||
|
||||
from .accounts import Account, AccountStore
|
||||
|
||||
|
||||
@dataclass
|
||||
class MessageSummary:
|
||||
uid: str
|
||||
subject: str
|
||||
sender: str
|
||||
date: str
|
||||
snippet: str = ""
|
||||
flags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class FullMessage:
|
||||
uid: str
|
||||
subject: str
|
||||
sender: str
|
||||
to: str
|
||||
date: str
|
||||
body_text: str
|
||||
body_html: str
|
||||
flags: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _open(account: Account, password: str, mailbox: str = "INBOX") -> Iterator[imaplib.IMAP4_SSL]:
|
||||
conn = imaplib.IMAP4_SSL(account.imap_host, account.imap_port)
|
||||
try:
|
||||
conn.login(account.email, password)
|
||||
# SELECT for read-write, EXAMINE for read-only. Use SELECT so we can
|
||||
# add/remove flags later (label/archive). Most reads still tolerate
|
||||
# the implicit \Seen behaviour Gmail applies; we set PEEK below.
|
||||
typ, _ = conn.select(mailbox)
|
||||
if typ != "OK":
|
||||
raise RuntimeError(f"SELECT {mailbox} failed for {account.email}")
|
||||
yield conn
|
||||
finally:
|
||||
try:
|
||||
conn.logout()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _decode_header(raw: str | None) -> str:
|
||||
if not raw:
|
||||
return ""
|
||||
parts = email.header.decode_header(raw)
|
||||
out = []
|
||||
for chunk, enc in parts:
|
||||
if isinstance(chunk, bytes):
|
||||
try:
|
||||
out.append(chunk.decode(enc or "utf-8", errors="replace"))
|
||||
except LookupError:
|
||||
out.append(chunk.decode("utf-8", errors="replace"))
|
||||
else:
|
||||
out.append(chunk)
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def list_inbox(
|
||||
store: AccountStore,
|
||||
email_addr: str,
|
||||
limit: int = 20,
|
||||
mailbox: str = "INBOX",
|
||||
) -> list[MessageSummary]:
|
||||
account = store.get(email_addr)
|
||||
password = store.password_for(email_addr)
|
||||
with _open(account, password, mailbox) as conn:
|
||||
typ, data = conn.uid("search", None, "ALL")
|
||||
if typ != "OK":
|
||||
raise RuntimeError(f"SEARCH ALL failed for {email_addr}")
|
||||
uids = data[0].split()[-limit:][::-1] # most recent first
|
||||
return [_fetch_summary(conn, uid.decode()) for uid in uids]
|
||||
|
||||
|
||||
def search(
|
||||
store: AccountStore,
|
||||
email_addr: str,
|
||||
query: str,
|
||||
limit: int = 20,
|
||||
mailbox: str = "INBOX",
|
||||
) -> list[MessageSummary]:
|
||||
"""Run an IMAP SEARCH. `query` is a raw IMAP search expression, e.g.
|
||||
`FROM alice@example.com`, `UNSEEN`, `SUBJECT "invoice"`, `SINCE 1-Jan-2026`.
|
||||
"""
|
||||
account = store.get(email_addr)
|
||||
password = store.password_for(email_addr)
|
||||
with _open(account, password, mailbox) as conn:
|
||||
typ, data = conn.uid("search", None, query)
|
||||
if typ != "OK":
|
||||
raise RuntimeError(f"SEARCH {query!r} failed for {email_addr}")
|
||||
uids = data[0].split()[-limit:][::-1]
|
||||
return [_fetch_summary(conn, uid.decode()) for uid in uids]
|
||||
|
||||
|
||||
def read_email(
|
||||
store: AccountStore,
|
||||
email_addr: str,
|
||||
uid: str,
|
||||
mailbox: str = "INBOX",
|
||||
) -> FullMessage:
|
||||
account = store.get(email_addr)
|
||||
password = store.password_for(email_addr)
|
||||
with _open(account, password, mailbox) as conn:
|
||||
# BODY.PEEK[] avoids setting \Seen automatically.
|
||||
typ, data = conn.uid("fetch", uid, "(FLAGS BODY.PEEK[])")
|
||||
if typ != "OK" or not data or data[0] is None:
|
||||
raise RuntimeError(f"FETCH uid={uid} failed for {email_addr}")
|
||||
meta, raw = data[0]
|
||||
flags = _parse_flags(meta.decode() if isinstance(meta, bytes) else meta)
|
||||
msg: EmailMessage = email.message_from_bytes(raw, policy=email.policy.default)
|
||||
body_text = ""
|
||||
body_html = ""
|
||||
if msg.is_multipart():
|
||||
for part in msg.walk():
|
||||
ctype = part.get_content_type()
|
||||
if ctype == "text/plain" and not body_text:
|
||||
body_text = part.get_content()
|
||||
elif ctype == "text/html" and not body_html:
|
||||
body_html = part.get_content()
|
||||
else:
|
||||
ctype = msg.get_content_type()
|
||||
if ctype == "text/html":
|
||||
body_html = msg.get_content()
|
||||
else:
|
||||
body_text = msg.get_content()
|
||||
return FullMessage(
|
||||
uid=uid,
|
||||
subject=_decode_header(msg["Subject"]),
|
||||
sender=_decode_header(msg["From"]),
|
||||
to=_decode_header(msg["To"]),
|
||||
date=_decode_header(msg["Date"]),
|
||||
body_text=body_text,
|
||||
body_html=body_html,
|
||||
flags=flags,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_summary(conn: imaplib.IMAP4_SSL, uid: str) -> MessageSummary:
|
||||
typ, data = conn.uid(
|
||||
"fetch",
|
||||
uid,
|
||||
"(FLAGS BODY.PEEK[HEADER.FIELDS (SUBJECT FROM DATE)])",
|
||||
)
|
||||
if typ != "OK" or not data or data[0] is None:
|
||||
return MessageSummary(uid=uid, subject="(fetch failed)", sender="", date="")
|
||||
meta, raw = data[0]
|
||||
flags = _parse_flags(meta.decode() if isinstance(meta, bytes) else meta)
|
||||
headers = email.message_from_bytes(raw, policy=email.policy.default)
|
||||
return MessageSummary(
|
||||
uid=uid,
|
||||
subject=_decode_header(headers["Subject"]),
|
||||
sender=_decode_header(headers["From"]),
|
||||
date=_decode_header(headers["Date"]),
|
||||
flags=flags,
|
||||
)
|
||||
|
||||
|
||||
def _parse_flags(meta: str) -> list[str]:
|
||||
# meta looks like: b'<uid> (FLAGS (\\Seen \\Answered) BODY[...] {1234}'
|
||||
start = meta.find("FLAGS (")
|
||||
if start < 0:
|
||||
return []
|
||||
end = meta.find(")", start)
|
||||
if end < 0:
|
||||
return []
|
||||
return meta[start + len("FLAGS (") : end].split()
|
||||
102
nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/server.py
Normal file
102
nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/server.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
"""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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue