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:
DannyDannyDanny 2026-05-02 14:15:10 +02:00
parent 771cc58076
commit af9f735abc
9 changed files with 559 additions and 3 deletions

View file

@ -2,13 +2,27 @@
{ config, lib, pkgs, ... }:
let
# Telegram user ID(s) gitignored, not committed to public repo.
# Telegram user ID(s) - gitignored, not committed to public repo.
# Create openclaw-allow-from.nix with e.g.: [ 12345678 ]
allowFromPath = ./openclaw-allow-from.nix;
openclawAllowFrom = if builtins.pathExists allowFromPath then import allowFromPath else [ ];
haraGmailMcp = pkgs.callPackage ../pkgs/hara-gmail-mcp { };
haraMcpServersJson = builtins.toJSON {
mcpServers = {
gmail = {
command = "${haraGmailMcp}/bin/hara-gmail-mcp";
args = [ ];
env = { };
};
};
};
in
{
imports = [ ./phantom-ship-hardware.nix ];
imports = [
./phantom-ship-hardware.nix
../pkgs/hara-gmail-mcp/module.nix
];
networking.hostName = "phantom-ship";
networking.useDHCP = lib.mkDefault true;
@ -140,7 +154,7 @@ in
# claude needs a PTY; wrap with script(1). /dev/null discards the typescript.
# Permission bypass lives in ~/.claude/settings.json (permissions.defaultMode)
# — using the CLI flag triggers an interactive warning dialog at startup.
ExecStart = ''${pkgs.util-linux}/bin/script -qfc "${pkgs.claude-code}/bin/claude --channels plugin:telegram@claude-plugins-official" /dev/null'';
ExecStart = ''${pkgs.util-linux}/bin/script -qfc "${pkgs.claude-code}/bin/claude --channels plugin:telegram@claude-plugins-official --mcp-config /etc/hara/mcp-servers.json" /dev/null'';
Restart = "always";
RestartSec = 5;
};
@ -152,6 +166,33 @@ in
"d /var/lib/openclaw/repos 0750 openclaw openclaw - -"
];
# Hara Gmail MCP server (path 1: IMAP+SMTP). Replaced by an OAuth2
# Gmail+Calendar server in path 2.
services.hara-gmail-mcp = {
enable = true;
package = haraGmailMcp;
accounts = [
{
email = "powerhouseplayer@gmail.com";
password_file = "/etc/openclaw/gmail-powerhouseplayer-app-password";
}
{
email = "wildstylewarrior@gmail.com";
password_file = "/etc/openclaw/gmail-wildstylewarrior-app-password";
}
{
email = "danielth95@gmail.com";
password_file = "/etc/openclaw/gmail-danielth95-app-password";
}
];
};
# MCP server registry consumed by claude-channels via --mcp-config.
environment.etc."hara/mcp-servers.json" = {
text = haraMcpServersJson;
mode = "0644";
};
# Git config for the openclaw user: credential helper reads PAT from file.
# PAT (not in repo): /etc/openclaw/github-token (fine-grained, scoped to specific repos)
environment.etc."openclaw/gitconfig" = {

View file

@ -0,0 +1,22 @@
# Gmail MCP server for Hara.
#
# Path 1 implementation: IMAP for read/sort, SMTP for reply.
# Slated for replacement by an OAuth2 + Gmail API + Calendar API server later.
{ python3Packages }:
python3Packages.buildPythonApplication {
pname = "hara-gmail-mcp";
version = "0.1.0";
pyproject = true;
src = ./.;
nativeBuildInputs = [ python3Packages.setuptools ];
propagatedBuildInputs = [ python3Packages.mcp ];
# The server is launched via stdio by Claude Code; no tests yet.
doCheck = false;
meta = {
description = "Gmail MCP server for Hara (IMAP+SMTP, throwaway pre-OAuth2)";
mainProgram = "hara-gmail-mcp";
};
}

View file

@ -0,0 +1,71 @@
# NixOS module for the Hara Gmail MCP server.
#
# Generates /etc/hara/gmail-accounts.json from declarative options and
# exposes the server binary through the dotfiles flake's pkgs set. Wiring
# the server into the claude-channels systemd service ExecStart is done
# by the host (phantom-ship.nix) so this module stays composable.
{ config, lib, pkgs, ... }:
let
cfg = config.services.hara-gmail-mcp;
package = pkgs.callPackage ./. { };
accountsJson = builtins.toJSON {
accounts = map (a: {
inherit (a) email password_file;
imap_host = a.imapHost;
imap_port = a.imapPort;
smtp_host = a.smtpHost;
smtp_port = a.smtpPort;
}) cfg.accounts;
};
in
{
options.services.hara-gmail-mcp = {
enable = lib.mkEnableOption "Hara Gmail MCP server (IMAP+SMTP)";
package = lib.mkOption {
type = lib.types.package;
default = package;
description = "The hara-gmail-mcp package to use.";
};
accounts = lib.mkOption {
description = "Gmail accounts the MCP server should expose.";
type = lib.types.listOf (lib.types.submodule {
options = {
email = lib.mkOption {
type = lib.types.str;
example = "user@example.com";
};
password_file = lib.mkOption {
type = lib.types.path;
description = "Path to the file containing the IMAP/SMTP app password.";
};
imapHost = lib.mkOption {
type = lib.types.str;
default = "imap.gmail.com";
};
imapPort = lib.mkOption {
type = lib.types.port;
default = 993;
};
smtpHost = lib.mkOption {
type = lib.types.str;
default = "smtp.gmail.com";
};
smtpPort = lib.mkOption {
type = lib.types.port;
default = 465;
};
};
});
};
};
config = lib.mkIf cfg.enable {
environment.etc."hara/gmail-accounts.json" = {
text = accountsJson;
mode = "0644";
};
};
}

View file

@ -0,0 +1,18 @@
[build-system]
requires = ["setuptools>=68"]
build-backend = "setuptools.build_meta"
[project]
name = "hara-gmail-mcp"
version = "0.1.0"
description = "Gmail MCP server for Hara (IMAP+SMTP, throwaway pre-OAuth2)"
requires-python = ">=3.11"
dependencies = [
"mcp>=1.0.0",
]
[project.scripts]
hara-gmail-mcp = "hara_gmail_mcp.__main__:main"
[tool.setuptools.packages.find]
where = ["src"]

View 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()

View 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

View 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()

View 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()