hara-gmail-mcp: add mark_read and archive tools (v0.2.0)

Adds two write-capable IMAP tools:
- gmail_mark_read: sets \Seen flag on a message
- gmail_archive: copies to [Gmail]/All Mail and removes from INBOX

The IMAP connection already used SELECT (read-write mode); this just
exposes the mutation surface through MCP.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hara 2026-05-03 07:14:42 +02:00
parent 8056e510c5
commit 4d2e40455d
4 changed files with 65 additions and 6 deletions

View file

@ -6,7 +6,7 @@
python3Packages.buildPythonApplication { python3Packages.buildPythonApplication {
pname = "hara-gmail-mcp"; pname = "hara-gmail-mcp";
version = "0.1.0"; version = "0.2.0";
pyproject = true; pyproject = true;
src = ./.; src = ./.;
nativeBuildInputs = [ python3Packages.setuptools ]; nativeBuildInputs = [ python3Packages.setuptools ];

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hara-gmail-mcp" name = "hara-gmail-mcp"
version = "0.1.0" version = "0.2.0"
description = "Gmail MCP server for Hara (IMAP+SMTP, throwaway pre-OAuth2)" description = "Gmail MCP server for Hara (IMAP+SMTP, throwaway pre-OAuth2)"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [

View file

@ -153,6 +153,34 @@ def read_email(
) )
def mark_read(
store: AccountStore,
email_addr: str,
uid: str,
mailbox: str = "INBOX",
) -> None:
"""Mark a message as read by adding the \\Seen flag."""
account = store.get(email_addr)
password = store.password_for(email_addr)
with _open(account, password, mailbox) as conn:
conn.uid("store", uid, "+FLAGS", r"(\Seen)")
def archive(
store: AccountStore,
email_addr: str,
uid: str,
mailbox: str = "INBOX",
) -> None:
"""Archive a message: copy to All Mail then delete from INBOX."""
account = store.get(email_addr)
password = store.password_for(email_addr)
with _open(account, password, mailbox) as conn:
conn.uid("copy", uid, "[Gmail]/All Mail")
conn.uid("store", uid, "+FLAGS", r"(\Deleted)")
conn.expunge()
def _fetch_summary(conn: imaplib.IMAP4_SSL, uid: str) -> MessageSummary: def _fetch_summary(conn: imaplib.IMAP4_SSL, uid: str) -> MessageSummary:
typ, data = conn.uid( typ, data = conn.uid(
"fetch", "fetch",

View file

@ -1,14 +1,15 @@
"""Hara Gmail MCP server. """Hara Gmail MCP server.
Exposes a small toolset for reading and (later) replying to mail across Exposes a small toolset for reading and writing mail across the configured
the configured Gmail accounts. v1 ships read-only tools; reply/archive/label Gmail accounts.
follow once Hara is using these reliably.
Tools: Tools:
list_accounts() list configured accounts list_accounts() list configured accounts
list_inbox(email, limit) recent messages from an account list_inbox(email, limit) recent messages from an account
search(email, query, limit) IMAP SEARCH wrapper search(email, query, limit) IMAP SEARCH wrapper
read_email(email, uid) full body of one message read_email(email, uid) full body of one message
mark_read(email, uid) mark a message as read
archive(email, uid) archive a message (remove from INBOX)
""" """
from __future__ import annotations from __future__ import annotations
@ -21,7 +22,7 @@ from dataclasses import asdict
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from .accounts import AccountStore from .accounts import AccountStore
from .imap_client import list_inbox, read_email, search from .imap_client import archive, list_inbox, mark_read, read_email, search
logger = logging.getLogger("hara_gmail_mcp") logger = logging.getLogger("hara_gmail_mcp")
@ -92,6 +93,36 @@ def gmail_read_email(email: str, uid: str) -> str:
return json.dumps(asdict(msg), ensure_ascii=False) return json.dumps(asdict(msg), ensure_ascii=False)
@mcp.tool()
def gmail_mark_read(email: str, uid: str) -> str:
"""Mark a message as read (sets the \\Seen flag).
Args:
email: which configured account
uid: the message UID (returned by gmail_list_inbox or gmail_search)
Returns:
JSON object with ok and uid.
"""
mark_read(_get_store(), email, uid=uid)
return json.dumps({"ok": True, "uid": uid})
@mcp.tool()
def gmail_archive(email: str, uid: str) -> str:
"""Archive a message (copies to All Mail, removes from INBOX).
Args:
email: which configured account
uid: the message UID (returned by gmail_list_inbox or gmail_search)
Returns:
JSON object with ok and uid.
"""
archive(_get_store(), email, uid=uid)
return json.dumps({"ok": True, "uid": uid})
def main() -> None: def main() -> None:
logging.basicConfig( logging.basicConfig(
level=os.environ.get("HARA_GMAIL_LOG_LEVEL", "INFO"), level=os.environ.get("HARA_GMAIL_LOG_LEVEL", "INFO"),