diff --git a/nixos/hosts/phantom-ship.nix b/nixos/hosts/phantom-ship.nix index e89f231..a298360 100644 --- a/nixos/hosts/phantom-ship.nix +++ b/nixos/hosts/phantom-ship.nix @@ -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" = { diff --git a/nixos/pkgs/hara-gmail-mcp/default.nix b/nixos/pkgs/hara-gmail-mcp/default.nix new file mode 100644 index 0000000..e82523e --- /dev/null +++ b/nixos/pkgs/hara-gmail-mcp/default.nix @@ -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"; + }; +} diff --git a/nixos/pkgs/hara-gmail-mcp/module.nix b/nixos/pkgs/hara-gmail-mcp/module.nix new file mode 100644 index 0000000..5c9ae38 --- /dev/null +++ b/nixos/pkgs/hara-gmail-mcp/module.nix @@ -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"; + }; + }; +} diff --git a/nixos/pkgs/hara-gmail-mcp/pyproject.toml b/nixos/pkgs/hara-gmail-mcp/pyproject.toml new file mode 100644 index 0000000..b2a985e --- /dev/null +++ b/nixos/pkgs/hara-gmail-mcp/pyproject.toml @@ -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"] diff --git a/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/__init__.py b/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/__main__.py b/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/__main__.py new file mode 100644 index 0000000..6372ee6 --- /dev/null +++ b/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/__main__.py @@ -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() diff --git a/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/accounts.py b/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/accounts.py new file mode 100644 index 0000000..ea3fa7d --- /dev/null +++ b/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/accounts.py @@ -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 diff --git a/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/imap_client.py b/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/imap_client.py new file mode 100644 index 0000000..34f2e29 --- /dev/null +++ b/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/imap_client.py @@ -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' (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() diff --git a/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/server.py b/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/server.py new file mode 100644 index 0000000..797d41c --- /dev/null +++ b/nixos/pkgs/hara-gmail-mcp/src/hara_gmail_mcp/server.py @@ -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()