hypermail-mcp 0.7.14 → 0.7.16
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.
- package/README.md +26 -6
- package/dist/cli.js +324 -151
- package/dist/cli.js.map +1 -1
- package/examples/hermes/README.md +65 -0
- package/examples/hermes/hypermail_new_email_poller.py +172 -0
- package/examples/hermes/jobs.example.json +21 -0
- package/package.json +3 -2
|
@@ -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.
|
|
3
|
+
"version": "0.7.16",
|
|
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",
|