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
|
|
@ -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" = {
|
||||
|
|
|
|||
22
nixos/pkgs/hara-gmail-mcp/default.nix
Normal file
22
nixos/pkgs/hara-gmail-mcp/default.nix
Normal 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";
|
||||
};
|
||||
}
|
||||
71
nixos/pkgs/hara-gmail-mcp/module.nix
Normal file
71
nixos/pkgs/hara-gmail-mcp/module.nix
Normal 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";
|
||||
};
|
||||
};
|
||||
}
|
||||
18
nixos/pkgs/hara-gmail-mcp/pyproject.toml
Normal file
18
nixos/pkgs/hara-gmail-mcp/pyproject.toml
Normal 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"]
|
||||
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