hypermail-mcp 0.7.13 → 0.7.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ # Hermes Hypermail poller example
2
+
3
+ This example shows one way to wire Hypermail's pull-based `get_new_emails`
4
+ tool into a Hermes scheduler job.
5
+
6
+ Hypermail does not run background inbox watchers itself. Instead, an agent
7
+ harness such as Hermes can run a small polling script on a schedule. The script
8
+ calls Hypermail over MCP, stores any returned email payload, and starts a Hermes
9
+ agent to handle that payload.
10
+
11
+ ## Files
12
+
13
+ - `hypermail_new_email_poller.py` — quiet polling script intended to run from a
14
+ Hermes job.
15
+ - `jobs.example.json` — sanitized Hermes interval job entry that runs the
16
+ poller every minute.
17
+
18
+ ## Expected Hermes config
19
+
20
+ The poller assumes Hermes already has a Hypermail MCP server named `hypermail`
21
+ in its config:
22
+
23
+ ```yaml
24
+ mcp_servers:
25
+ hypermail:
26
+ command: npx
27
+ args: ["-y", "hypermail-mcp"]
28
+ env:
29
+ HYPERMAIL_KEY: "..."
30
+ HYPERMAIL_DATA_DIR: "..."
31
+ ```
32
+
33
+ By default the script reads `$HERMES_HOME/config.yaml`, with `HERMES_HOME`
34
+ defaulting to `~/.hermes`. You can override paths and behavior with:
35
+
36
+ - `HERMES_HOME`
37
+ - `HERMES_CONFIG`
38
+ - `HERMES_PROFILE`
39
+ - `HYPERMAIL_POLLER_STATE_DIR`
40
+ - `HYPERMAIL_POLLER_LIMIT`
41
+ - `HYPERMAIL_POLLER_SOURCE`
42
+ - `HYPERMAIL_POLLER_POLICY`
43
+
44
+ ## How it works
45
+
46
+ 1. Hermes runs `hypermail_new_email_poller.py` every minute with `no_agent: true`.
47
+ 2. The script takes a file lock so overlapping runs exit quietly.
48
+ 3. It starts the configured Hypermail MCP server and calls `get_new_emails` with
49
+ a bounded limit.
50
+ 4. If no email is returned, it exits without output.
51
+ 5. If an email is returned, it writes the payload under the poller state
52
+ directory and spawns `hermes chat` with a prompt that points to that payload.
53
+ 6. The spawned Hermes agent reads the payload, uses Hypermail tools for any
54
+ follow-up reads/actions, acts according to the user's memory and policy, asks
55
+ when uncertain, and notifies the user after actions.
56
+
57
+ ## Adapting the example
58
+
59
+ Copy the script into the directory where Hermes expects job scripts, then adapt
60
+ `jobs.example.json` to your Hermes job registry format. Keep the job as
61
+ `no_agent: true`; the script itself starts the agent only when new mail exists.
62
+
63
+ Set `HYPERMAIL_POLLER_POLICY` or edit the prompt in the script to describe your
64
+ mailbox handling policy. Avoid encoding destructive default behavior unless your
65
+ agent will ask first or your policy is explicit.
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env python3
2
+ """Poll Hypermail MCP for one new inbox email and hand it to Hermes.
3
+
4
+ This example is intended to run as a quiet Hermes scheduler job. It reads the
5
+ Hypermail MCP server definition from Hermes config, calls `get_new_emails`,
6
+ writes any returned email payload to disk, and spawns a Hermes agent to handle
7
+ that payload.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import fcntl
13
+ import json
14
+ import os
15
+ import subprocess
16
+ import sys
17
+ import time
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import yaml
22
+
23
+ HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
24
+ CONFIG_PATH = Path(os.environ.get("HERMES_CONFIG", HERMES_HOME / "config.yaml"))
25
+ STATE_DIR = Path(os.environ.get("HYPERMAIL_POLLER_STATE_DIR", HERMES_HOME / "hypermail-poller"))
26
+ INCOMING_DIR = STATE_DIR / "incoming"
27
+ LOG_DIR = STATE_DIR / "logs"
28
+ LOCK_PATH = STATE_DIR / "poller.lock"
29
+ PROFILE = os.environ.get("HERMES_PROFILE", "default")
30
+ SOURCE = os.environ.get("HYPERMAIL_POLLER_SOURCE", "hypermail-poller")
31
+ LIMIT = int(os.environ.get("HYPERMAIL_POLLER_LIMIT", "1"))
32
+ USER_POLICY = os.environ.get(
33
+ "HYPERMAIL_POLLER_POLICY",
34
+ "<Add your user-specific email handling policy here.>",
35
+ )
36
+
37
+
38
+ def load_hypermail_config() -> tuple[str, list[str], dict[str, str]]:
39
+ data = yaml.safe_load(CONFIG_PATH.read_text()) or {}
40
+ server = ((data.get("mcp_servers") or {}).get("hypermail") or {})
41
+ command = server.get("command")
42
+ if not command:
43
+ raise RuntimeError("mcp_servers.hypermail.command is missing")
44
+ args = server.get("args") or []
45
+ if not isinstance(args, list):
46
+ raise RuntimeError("mcp_servers.hypermail.args must be a list")
47
+ env = {str(k): str(v) for k, v in (server.get("env") or {}).items()}
48
+ return str(command), [str(a) for a in args], env
49
+
50
+
51
+ async def call_get_new_emails_async() -> dict[str, Any]:
52
+ from datetime import timedelta
53
+
54
+ from mcp import ClientSession, StdioServerParameters
55
+ from mcp.client.stdio import stdio_client
56
+
57
+ command, args, mcp_env = load_hypermail_config()
58
+ env = os.environ.copy()
59
+ env.update(mcp_env)
60
+ server_params = StdioServerParameters(command=command, args=args, env=env)
61
+ async with stdio_client(server_params) as (read_stream, write_stream):
62
+ async with ClientSession(read_stream, write_stream) as session:
63
+ await session.initialize()
64
+ result = await session.call_tool(
65
+ "get_new_emails",
66
+ {"limit": LIMIT},
67
+ read_timeout_seconds=timedelta(seconds=60),
68
+ )
69
+ structured = getattr(result, "structuredContent", None)
70
+ if structured is not None:
71
+ return structured
72
+ content = getattr(result, "content", None) or []
73
+ for item in content:
74
+ text = getattr(item, "text", None)
75
+ if text:
76
+ try:
77
+ return json.loads(text)
78
+ except Exception:
79
+ continue
80
+ return {"count": 0, "emails": [], "errors": []}
81
+
82
+
83
+ def call_get_new_emails() -> dict[str, Any]:
84
+ import anyio
85
+
86
+ return anyio.run(call_get_new_emails_async)
87
+
88
+
89
+ def spawn_agent(payload_path: Path) -> None:
90
+ prompt = f"""A Hypermail poll found one new inbox email.
91
+
92
+ The full JSON payload is saved at:
93
+ {payload_path}
94
+
95
+ Do this now:
96
+ 1. Read the JSON payload.
97
+ 2. Handle only the email in that payload.
98
+ 3. If bodyTruncated is true or the body is insufficient, use Hypermail MCP `read_email` with the payload account/id.
99
+ 4. Act according to the user's memory and policy.
100
+ 5. User-specific policy placeholder:
101
+ {USER_POLICY}
102
+ 6. If there is any doubt, ask the user before taking action.
103
+ 7. Never permanently delete anything unless the user explicitly asks.
104
+ 8. Notify the user after any action, including what you did and why.
105
+ """
106
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
107
+ stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
108
+ log_file = LOG_DIR / f"agent-{stamp}.log"
109
+ with log_file.open("ab") as log:
110
+ subprocess.Popen(
111
+ [
112
+ "hermes",
113
+ "--profile",
114
+ PROFILE,
115
+ "chat",
116
+ "-Q",
117
+ "--source",
118
+ SOURCE,
119
+ "-q",
120
+ prompt,
121
+ ],
122
+ stdout=log,
123
+ stderr=subprocess.STDOUT,
124
+ start_new_session=True,
125
+ )
126
+
127
+
128
+ def main() -> int:
129
+ parser = argparse.ArgumentParser()
130
+ parser.add_argument("--print-empty", action="store_true", help="print a diagnostic line when no mail is found")
131
+ ns = parser.parse_args()
132
+
133
+ STATE_DIR.mkdir(parents=True, exist_ok=True)
134
+ INCOMING_DIR.mkdir(parents=True, exist_ok=True)
135
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
136
+
137
+ with LOCK_PATH.open("w") as lock:
138
+ try:
139
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
140
+ except BlockingIOError:
141
+ return 0
142
+
143
+ data = call_get_new_emails()
144
+ if data.get("errors"):
145
+ print(json.dumps({"errors": data.get("errors")}, ensure_ascii=False), file=sys.stderr)
146
+ emails = data.get("emails") or []
147
+ if not emails:
148
+ if ns.print_empty:
149
+ print("count=0")
150
+ return 0
151
+
152
+ email = emails[0]
153
+ stamp = time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())
154
+ payload_path = INCOMING_DIR / f"email-{stamp}-{os.getpid()}.json"
155
+ payload_path.write_text(
156
+ json.dumps({"count": 1, "emails": [email], "errors": data.get("errors") or []}, ensure_ascii=False, indent=2),
157
+ encoding="utf-8",
158
+ )
159
+ os.chmod(payload_path, 0o600)
160
+ spawn_agent(payload_path)
161
+
162
+ with (LOG_DIR / "poller.log").open("a", encoding="utf-8") as log:
163
+ log.write(f"{time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime())} queued {payload_path}\n")
164
+ return 0
165
+
166
+
167
+ if __name__ == "__main__":
168
+ try:
169
+ raise SystemExit(main())
170
+ except Exception as e:
171
+ print(f"hypermail poller error: {e}", file=sys.stderr)
172
+ raise SystemExit(1)
@@ -0,0 +1,21 @@
1
+ {
2
+ "jobs": [
3
+ {
4
+ "id": "example-hypermail-new-email-poller",
5
+ "name": "hypermail-new-email-poller",
6
+ "prompt": "Poll Hypermail MCP for at most one new inbox email. The script is the job: it stays silent when no mail is found; if a new email is found, it stores the payload and spawns a Hermes agent to handle it according to the user's memory and policy. Do not recursively schedule jobs.",
7
+ "skills": [],
8
+ "script": "hypermail_new_email_poller.py",
9
+ "no_agent": true,
10
+ "schedule": {
11
+ "kind": "interval",
12
+ "minutes": 1,
13
+ "display": "every 1m"
14
+ },
15
+ "schedule_display": "every 1m",
16
+ "enabled": true,
17
+ "deliver": "origin",
18
+ "enabled_toolsets": ["terminal", "file"]
19
+ }
20
+ ]
21
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypermail-mcp",
3
- "version": "0.7.13",
3
+ "version": "0.7.15",
4
4
  "description": "Unified email MCP server — operate any inbox (Outlook now, IMAP/Gmail later) by passing an email address.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -10,7 +10,8 @@
10
10
  "files": [
11
11
  "dist",
12
12
  "README.md",
13
- "LICENSE"
13
+ "LICENSE",
14
+ "examples"
14
15
  ],
15
16
  "scripts": {
16
17
  "build": "tsup",