hypermail-mcp 0.4.3 → 0.6.0

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/dist/cli.js CHANGED
@@ -1,85 +1,36 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // src/cli.ts
4
+ import { randomBytes as randomBytes2 } from "crypto";
5
+
3
6
  // src/server.ts
4
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
9
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
10
  import { randomUUID as randomUUID6 } from "crypto";
8
11
  import { createServer as createHttpServer } from "http";
12
+ import path4 from "path";
9
13
 
10
14
  // src/store/account-store.ts
15
+ import { promises as fs2 } from "fs";
16
+ import path2 from "path";
17
+
18
+ // src/store/crypto.ts
11
19
  import {
12
20
  createCipheriv,
13
21
  createDecipheriv,
14
22
  randomBytes,
15
- createHash
23
+ createHash,
24
+ scryptSync,
25
+ timingSafeEqual
16
26
  } from "crypto";
17
27
  import { promises as fs } from "fs";
18
28
  import { homedir } from "os";
19
29
  import path from "path";
20
- var FILE_NAME = "accounts.json.enc";
21
30
  var ALGO = "aes-256-gcm";
22
31
  var KEY_LEN = 32;
23
- var AccountStore = class _AccountStore {
24
- constructor(filePath, key, data) {
25
- this.filePath = filePath;
26
- this.key = key;
27
- this.data = data;
28
- }
29
- filePath;
30
- key;
31
- data;
32
- static async open(opts = {}) {
33
- const dataDir = resolveDataDir(opts.dataDir);
34
- await fs.mkdir(dataDir, { recursive: true, mode: 448 });
35
- const filePath = path.join(dataDir, FILE_NAME);
36
- const key = opts.key ?? await resolveKey(dataDir);
37
- let data;
38
- try {
39
- const buf = await fs.readFile(filePath);
40
- data = decrypt(buf, key);
41
- } catch (err) {
42
- if (err.code === "ENOENT") {
43
- data = { version: 1, accounts: [] };
44
- } else {
45
- throw err;
46
- }
47
- }
48
- return new _AccountStore(filePath, key, data);
49
- }
50
- listAccounts() {
51
- return this.data.accounts.map((a) => ({ ...a }));
52
- }
53
- getAccount(email) {
54
- const norm = email.trim().toLowerCase();
55
- const rec = this.data.accounts.find((a) => a.email.toLowerCase() === norm);
56
- return rec ? { ...rec } : void 0;
57
- }
58
- async upsertAccount(rec) {
59
- const norm = rec.email.trim().toLowerCase();
60
- const next = { ...rec, email: norm };
61
- const idx = this.data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
62
- if (idx >= 0) this.data.accounts[idx] = next;
63
- else this.data.accounts.push(next);
64
- await this.flush();
65
- return { ...next };
66
- }
67
- async removeAccount(email) {
68
- const norm = email.trim().toLowerCase();
69
- const before = this.data.accounts.length;
70
- this.data.accounts = this.data.accounts.filter((a) => a.email.toLowerCase() !== norm);
71
- if (this.data.accounts.length === before) return false;
72
- await this.flush();
73
- return true;
74
- }
75
- async flush() {
76
- const buf = encrypt(this.data, this.key);
77
- const tmp = `${this.filePath}.${process.pid}.${Date.now()}.tmp`;
78
- await fs.writeFile(tmp, buf, { mode: 384 });
79
- await fs.rename(tmp, this.filePath);
80
- }
81
- };
82
32
  function encrypt(data, key) {
33
+ if (key.length !== KEY_LEN) throw new Error(`key must be ${KEY_LEN} bytes`);
83
34
  const iv = randomBytes(12);
84
35
  const cipher = createCipheriv(ALGO, key, iv);
85
36
  const plaintext = Buffer.from(JSON.stringify(data), "utf8");
@@ -88,20 +39,17 @@ function encrypt(data, key) {
88
39
  return Buffer.concat([Buffer.from([1]), iv, tag, ct]);
89
40
  }
90
41
  function decrypt(buf, key) {
91
- if (buf.length < 1 + 12 + 16 + 1) throw new Error("accounts file truncated");
42
+ if (key.length !== KEY_LEN) throw new Error(`key must be ${KEY_LEN} bytes`);
43
+ if (buf.length < 1 + 12 + 16 + 1) throw new Error("encrypted data truncated");
92
44
  const v = buf[0];
93
- if (v !== 1) throw new Error(`unsupported accounts file version: ${v}`);
45
+ if (v !== 1) throw new Error(`unsupported data version: ${v}`);
94
46
  const iv = buf.subarray(1, 13);
95
47
  const tag = buf.subarray(13, 29);
96
48
  const ct = buf.subarray(29);
97
49
  const decipher = createDecipheriv(ALGO, key, iv);
98
50
  decipher.setAuthTag(tag);
99
51
  const pt = Buffer.concat([decipher.update(ct), decipher.final()]);
100
- const parsed = JSON.parse(pt.toString("utf8"));
101
- if (parsed.version !== 1 || !Array.isArray(parsed.accounts)) {
102
- throw new Error("accounts file is malformed");
103
- }
104
- return parsed;
52
+ return JSON.parse(pt.toString("utf8"));
105
53
  }
106
54
  function resolveDataDir(explicit) {
107
55
  if (explicit && explicit.length > 0) return path.resolve(explicit);
@@ -138,6 +86,28 @@ async function resolveKey(dataDir) {
138
86
  await tryKeytarSet(gen);
139
87
  return gen;
140
88
  }
89
+ function hashApiKey(apiKey) {
90
+ const salt = randomBytes(16).toString("hex");
91
+ const hash = scryptSync(apiKey, salt, 32).toString("hex");
92
+ return `${salt}:${hash}`;
93
+ }
94
+ function verifyApiKey(apiKey, stored) {
95
+ const [salt, hash] = stored.split(":");
96
+ if (!salt || !hash) return false;
97
+ try {
98
+ const computed = scryptSync(apiKey, salt, 32);
99
+ const expected = Buffer.from(hash, "hex");
100
+ if (computed.length !== expected.length) return false;
101
+ return timingSafeEqual(computed, expected);
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+ async function writeAtomic(filePath, data) {
107
+ const tmp = `${filePath}.${process.pid}.${Date.now()}.tmp`;
108
+ await fs.writeFile(tmp, data, { mode: 384 });
109
+ await fs.rename(tmp, filePath);
110
+ }
141
111
  async function tryKeytarGet() {
142
112
  try {
143
113
  const mod = await import("keytar");
@@ -158,6 +128,188 @@ async function tryKeytarSet(key) {
158
128
  }
159
129
  }
160
130
 
131
+ // src/store/account-store.ts
132
+ var FILE_NAME = "accounts.json.enc";
133
+ var AccountStore = class _AccountStore {
134
+ constructor(filePath, key, data) {
135
+ this.filePath = filePath;
136
+ this.key = key;
137
+ this.data = data;
138
+ }
139
+ filePath;
140
+ key;
141
+ data;
142
+ static async open(opts = {}) {
143
+ const dataDir = resolveDataDir(opts.dataDir);
144
+ await fs2.mkdir(dataDir, { recursive: true, mode: 448 });
145
+ const filePath = path2.join(dataDir, FILE_NAME);
146
+ const key = opts.key ?? await resolveKey(dataDir);
147
+ let data;
148
+ try {
149
+ const buf = await fs2.readFile(filePath);
150
+ data = decrypt(buf, key);
151
+ } catch (err) {
152
+ if (err.code === "ENOENT") {
153
+ data = { version: 1, accounts: [] };
154
+ } else {
155
+ throw err;
156
+ }
157
+ }
158
+ return new _AccountStore(filePath, key, data);
159
+ }
160
+ listAccounts() {
161
+ return this.data.accounts.map((a) => ({ ...a }));
162
+ }
163
+ getAccount(email) {
164
+ const norm = email.trim().toLowerCase();
165
+ const rec = this.data.accounts.find((a) => a.email.toLowerCase() === norm);
166
+ return rec ? { ...rec } : void 0;
167
+ }
168
+ async upsertAccount(rec) {
169
+ const norm = rec.email.trim().toLowerCase();
170
+ const next = { ...rec, email: norm };
171
+ const idx = this.data.accounts.findIndex((a) => a.email.toLowerCase() === norm);
172
+ if (idx >= 0) this.data.accounts[idx] = next;
173
+ else this.data.accounts.push(next);
174
+ await this.flush();
175
+ return { ...next };
176
+ }
177
+ async removeAccount(email) {
178
+ const norm = email.trim().toLowerCase();
179
+ const before = this.data.accounts.length;
180
+ this.data.accounts = this.data.accounts.filter((a) => a.email.toLowerCase() !== norm);
181
+ if (this.data.accounts.length === before) return false;
182
+ await this.flush();
183
+ return true;
184
+ }
185
+ async flush() {
186
+ const buf = encrypt(this.data, this.key);
187
+ await writeAtomic(this.filePath, buf);
188
+ }
189
+ };
190
+
191
+ // src/store/agent-store.ts
192
+ import path3 from "path";
193
+ import { promises as fs3 } from "fs";
194
+ var FILE_NAME2 = "agents.json.enc";
195
+ var AgentStore = class _AgentStore {
196
+ constructor(filePath, key, data) {
197
+ this.filePath = filePath;
198
+ this.key = key;
199
+ this.data = data;
200
+ }
201
+ filePath;
202
+ key;
203
+ data;
204
+ static async open(opts = {}) {
205
+ const dataDir = resolveDataDir(opts.dataDir);
206
+ await fs3.mkdir(dataDir, { recursive: true, mode: 448 });
207
+ const filePath = path3.join(dataDir, FILE_NAME2);
208
+ const key = opts.key ?? await resolveKey(dataDir);
209
+ let data;
210
+ try {
211
+ const buf = await fs3.readFile(filePath);
212
+ data = decrypt(buf, key);
213
+ } catch (err) {
214
+ if (err.code === "ENOENT") {
215
+ data = { version: 1, agents: [] };
216
+ } else {
217
+ throw err;
218
+ }
219
+ }
220
+ return new _AgentStore(filePath, key, data);
221
+ }
222
+ // ── queries ──
223
+ listAgents() {
224
+ return this.data.agents.map((a) => ({ ...a }));
225
+ }
226
+ getAgent(id) {
227
+ const rec = this.data.agents.find((a) => a.id === id);
228
+ return rec ? { ...rec } : void 0;
229
+ }
230
+ /**
231
+ * Look up an agent by plaintext API key. Hashes the incoming key and
232
+ * compares against stored hashes with constant-time comparison.
233
+ * Returns undefined if no agent matches.
234
+ */
235
+ findAgentByApiKey(apiKey) {
236
+ for (const agent of this.data.agents) {
237
+ if (verifyApiKey(apiKey, agent.apiKeyHash)) {
238
+ return { ...agent };
239
+ }
240
+ }
241
+ return void 0;
242
+ }
243
+ // ── mutations ──
244
+ /**
245
+ * Add or update an agent. If `plaintextApiKey` is provided, it is hashed
246
+ * and stored; if omitted, the existing hash is preserved (useful for
247
+ * updates that don't change the key).
248
+ */
249
+ async upsertAgent(rec) {
250
+ const idx = this.data.agents.findIndex((a) => a.id === rec.id);
251
+ const existing = idx >= 0 ? this.data.agents[idx] : void 0;
252
+ const apiKeyHash = rec.plaintextApiKey ? hashApiKey(rec.plaintextApiKey) : existing?.apiKeyHash;
253
+ if (!apiKeyHash) {
254
+ throw new Error(
255
+ `agent ${rec.id}: must provide plaintextApiKey for new agents`
256
+ );
257
+ }
258
+ const next = {
259
+ id: rec.id,
260
+ apiKeyHash,
261
+ name: rec.name,
262
+ accounts: [...rec.accounts ?? []],
263
+ provisioning: rec.provisioning ?? false,
264
+ createdAt: existing?.createdAt ?? (/* @__PURE__ */ new Date()).toISOString()
265
+ };
266
+ if (idx >= 0) {
267
+ this.data.agents[idx] = next;
268
+ } else {
269
+ this.data.agents.push(next);
270
+ }
271
+ await this.flush();
272
+ return { ...next };
273
+ }
274
+ async removeAgent(id) {
275
+ const before = this.data.agents.length;
276
+ this.data.agents = this.data.agents.filter((a) => a.id !== id);
277
+ if (this.data.agents.length === before) return false;
278
+ await this.flush();
279
+ return true;
280
+ }
281
+ /**
282
+ * Assign an email account to an agent. Idempotent — no error if already
283
+ * assigned. Auto-assignment from `add_account` in HTTP mode calls this.
284
+ */
285
+ async assignAccount(agentId, email) {
286
+ const norm = email.trim().toLowerCase();
287
+ const agent = this.data.agents.find((a) => a.id === agentId);
288
+ if (!agent) throw new Error(`agent ${agentId} not found`);
289
+ if (!agent.accounts.includes(norm)) {
290
+ agent.accounts.push(norm);
291
+ await this.flush();
292
+ }
293
+ return { ...agent };
294
+ }
295
+ /**
296
+ * Remove an email account from an agent's assignments. Idempotent.
297
+ */
298
+ async unassignAccount(agentId, email) {
299
+ const norm = email.trim().toLowerCase();
300
+ const agent = this.data.agents.find((a) => a.id === agentId);
301
+ if (!agent) throw new Error(`agent ${agentId} not found`);
302
+ agent.accounts = agent.accounts.filter((a) => a !== norm);
303
+ await this.flush();
304
+ return { ...agent };
305
+ }
306
+ // ── persistence ──
307
+ async flush() {
308
+ const buf = encrypt(this.data, this.key);
309
+ await writeAtomic(this.filePath, buf);
310
+ }
311
+ };
312
+
161
313
  // src/providers/outlook/index.ts
162
314
  import { randomUUID as randomUUID2 } from "crypto";
163
315
  import { writeFileSync } from "fs";
@@ -338,34 +490,52 @@ var OutlookClientFactory = class {
338
490
  clientId;
339
491
  tenantId;
340
492
  cache = /* @__PURE__ */ new Map();
493
+ /** Serialize token refreshes per email to prevent concurrent-refresh races. */
494
+ refreshLocks = /* @__PURE__ */ new Map();
341
495
  get(account) {
342
496
  const key = account.email.toLowerCase();
343
497
  const existing = this.cache.get(key);
344
498
  if (existing) return existing;
345
499
  const store = this.store;
500
+ const refreshLocks = this.refreshLocks;
346
501
  const provider = {
347
502
  getAccessToken: async () => {
348
- const fresh = store.getAccount(account.email) ?? account;
349
- if (!isSerializedTokens(fresh.tokens)) {
350
- throw new Error(
351
- "Outlook account tokens are missing or corrupted \u2014 re-run add_account"
352
- );
503
+ const existing2 = refreshLocks.get(key);
504
+ if (existing2) {
505
+ try {
506
+ return await existing2;
507
+ } catch {
508
+ }
353
509
  }
354
- const tokens = fresh.tokens;
355
- const { accessToken, tokens: nextTokens } = await acquireAccessToken(
356
- tokens,
357
- void 0,
358
- this.clientId,
359
- this.tenantId
360
- );
361
- if (nextTokens.msalCache !== tokens.msalCache) {
362
- store.upsertAccount({
363
- ...fresh,
364
- tokens: nextTokens
365
- }).catch(() => {
366
- });
510
+ const promise = (async () => {
511
+ const fresh = store.getAccount(account.email) ?? account;
512
+ if (!isSerializedTokens(fresh.tokens)) {
513
+ throw new Error(
514
+ "Outlook account tokens are missing or corrupted \u2014 re-run add_account"
515
+ );
516
+ }
517
+ const tokens = fresh.tokens;
518
+ const { accessToken, tokens: nextTokens } = await acquireAccessToken(
519
+ tokens,
520
+ void 0,
521
+ this.clientId,
522
+ this.tenantId
523
+ );
524
+ if (nextTokens.msalCache !== tokens.msalCache) {
525
+ store.upsertAccount({
526
+ ...fresh,
527
+ tokens: nextTokens
528
+ }).catch(() => {
529
+ });
530
+ }
531
+ return accessToken;
532
+ })();
533
+ refreshLocks.set(key, promise);
534
+ try {
535
+ return await promise;
536
+ } finally {
537
+ refreshLocks.delete(key);
367
538
  }
368
- return accessToken;
369
539
  }
370
540
  };
371
541
  const client = Client.initWithMiddleware({ authProvider: provider });
@@ -1395,8 +1565,8 @@ async function markRead(clients, account, id, isRead) {
1395
1565
  async function createFolder(clients, account, input) {
1396
1566
  const client = clients.get(account);
1397
1567
  const imap = await client.getImap();
1398
- const path2 = input.parentFolderId ? `${input.parentFolderId}/${input.displayName}` : input.displayName;
1399
- const result = await imap.mailboxCreate(path2);
1568
+ const path5 = input.parentFolderId ? `${input.parentFolderId}/${input.displayName}` : input.displayName;
1569
+ const result = await imap.mailboxCreate(path5);
1400
1570
  return {
1401
1571
  id: result.path,
1402
1572
  displayName: result.path,
@@ -1713,6 +1883,8 @@ var GmailClientFactory = class {
1713
1883
  clientId;
1714
1884
  clientSecret;
1715
1885
  cache = /* @__PURE__ */ new Map();
1886
+ /** Serialize token-persist per email to prevent concurrent upsert races. */
1887
+ persistLocks = /* @__PURE__ */ new Map();
1716
1888
  get(account) {
1717
1889
  const key = account.email.toLowerCase();
1718
1890
  const existing = this.cache.get(key);
@@ -1731,21 +1903,29 @@ var GmailClientFactory = class {
1731
1903
  clientSecret: resolvedSecret
1732
1904
  });
1733
1905
  const store = this.store;
1906
+ const persistLocks = this.persistLocks;
1734
1907
  auth.on("tokens", (updated) => {
1735
1908
  if (!updated.refresh_token && !updated.access_token) return;
1736
- const fresh = store.getAccount(account.email) ?? account;
1737
- const currentTokens = isSerializedGmailTokens(fresh.tokens) ? fresh.tokens : tokens;
1738
- const nextTokens = {
1739
- ...currentTokens,
1740
- accessToken: updated.access_token ?? currentTokens.accessToken,
1741
- refreshToken: updated.refresh_token ?? currentTokens.refreshToken,
1742
- expiryDate: updated.expiry_date ?? currentTokens.expiryDate,
1743
- scopes: updated.scope ? updated.scope.split(" ") : currentTokens.scopes
1744
- };
1745
- store.upsertAccount({
1746
- ...fresh,
1747
- tokens: nextTokens
1748
- }).catch(() => {
1909
+ const existing2 = persistLocks.get(key);
1910
+ const chain = (existing2 ?? Promise.resolve()).then(async () => {
1911
+ const fresh = store.getAccount(account.email) ?? account;
1912
+ const currentTokens = isSerializedGmailTokens(fresh.tokens) ? fresh.tokens : tokens;
1913
+ const nextTokens = {
1914
+ ...currentTokens,
1915
+ accessToken: updated.access_token ?? currentTokens.accessToken,
1916
+ refreshToken: updated.refresh_token ?? currentTokens.refreshToken,
1917
+ expiryDate: updated.expiry_date ?? currentTokens.expiryDate,
1918
+ scopes: updated.scope ? updated.scope.split(" ") : currentTokens.scopes
1919
+ };
1920
+ await store.upsertAccount({
1921
+ ...fresh,
1922
+ tokens: nextTokens
1923
+ }).catch(() => {
1924
+ });
1925
+ });
1926
+ persistLocks.set(key, chain);
1927
+ chain.finally(() => {
1928
+ if (persistLocks.get(key) === chain) persistLocks.delete(key);
1749
1929
  });
1750
1930
  });
1751
1931
  const gmail = google.gmail({ version: "v1", auth });
@@ -2459,6 +2639,14 @@ function buildRegistry(opts) {
2459
2639
 
2460
2640
  // src/tools/shared.ts
2461
2641
  import { z } from "zod";
2642
+
2643
+ // src/markdown-to-html.ts
2644
+ import { marked } from "marked";
2645
+ function markdownToHtml(md) {
2646
+ return marked.parse(md, { async: false });
2647
+ }
2648
+
2649
+ // src/tools/shared.ts
2462
2650
  function ok(data, structuredContent) {
2463
2651
  const result = {
2464
2652
  content: [{ type: "text", text: JSON.stringify(data, null, 2) }]
@@ -2535,21 +2723,15 @@ var folderInfoOutputSchema = z.object({
2535
2723
  unreadItemCount: z.number()
2536
2724
  });
2537
2725
  function composeBody(input) {
2538
- const { body, isHtml = false, signature, style, includeSignature } = input;
2726
+ const { body, format, signature, style, includeSignature } = input;
2727
+ const htmlBody = format === "markdown" ? markdownToHtml(body) : body;
2539
2728
  const hasSignature = includeSignature && !!signature;
2540
2729
  const hasStyle = !!(style && (style.fontFamily || style.fontSize || style.fontColor));
2541
2730
  if (!hasSignature && !hasStyle) {
2542
- return { body, isHtml };
2731
+ return { body: htmlBody, isHtml: true };
2543
2732
  }
2544
2733
  const styleAttr = hasStyle ? buildStyleAttr(style) : "";
2545
- if (isHtml) {
2546
- let result2 = hasStyle ? `<div style="${styleAttr}">${body}</div>` : body;
2547
- if (hasSignature) result2 += `
2548
- <div class="signature">${signature}</div>`;
2549
- return { body: result2, isHtml: true };
2550
- }
2551
- const escaped = escapeHtml(body);
2552
- let result = `<div style="${styleAttr}">${escaped}</div>`;
2734
+ let result = hasStyle ? `<div style="${styleAttr}">${htmlBody}</div>` : htmlBody;
2553
2735
  if (hasSignature) result += `
2554
2736
  <div class="signature">${signature}</div>`;
2555
2737
  return { body: result, isHtml: true };
@@ -2561,9 +2743,6 @@ function buildStyleAttr(style) {
2561
2743
  if (style.fontColor) parts.push(`color: ${style.fontColor}`);
2562
2744
  return parts.join("; ");
2563
2745
  }
2564
- function escapeHtml(text) {
2565
- return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/\n/g, "<br>");
2566
- }
2567
2746
  function shouldRegister(name, tools) {
2568
2747
  if (tools.enabledTools) {
2569
2748
  return tools.enabledTools.has(name);
@@ -2575,9 +2754,27 @@ function shouldRegister(name, tools) {
2575
2754
  }
2576
2755
 
2577
2756
  // src/tools/accounts.ts
2757
+ import { promises as fs4 } from "fs";
2578
2758
  import { z as z2 } from "zod";
2759
+
2760
+ // src/tools/agent-context.ts
2761
+ function checkAccountAccess(agentContext, accountEmail) {
2762
+ if (!agentContext) return null;
2763
+ const norm = accountEmail.trim().toLowerCase();
2764
+ if (agentContext.accounts.some((a) => a.toLowerCase() === norm)) {
2765
+ return null;
2766
+ }
2767
+ return `Agent "${agentContext.agentId}" is not authorized for account "${accountEmail}"`;
2768
+ }
2769
+ function checkProvisioning(agentContext) {
2770
+ if (!agentContext) return null;
2771
+ if (agentContext.provisioning) return null;
2772
+ return `Agent "${agentContext.agentId}" does not have provisioning permission`;
2773
+ }
2774
+
2775
+ // src/tools/accounts.ts
2579
2776
  function registerAccountTools(server, ctx) {
2580
- const { store, registry, tools } = ctx;
2777
+ const { store, registry, tools, agentContext, agentStore } = ctx;
2581
2778
  const listAccountsOutputSchema = z2.object({
2582
2779
  accounts: z2.array(accountSummaryOutputSchema)
2583
2780
  });
@@ -2631,6 +2828,8 @@ function registerAccountTools(server, ctx) {
2631
2828
  outputSchema: addAccountOutputSchema
2632
2829
  },
2633
2830
  async (args) => {
2831
+ const permErr = checkProvisioning(agentContext ?? null);
2832
+ if (permErr) return fail(permErr);
2634
2833
  const provider = registry.get(args.provider);
2635
2834
  try {
2636
2835
  const res = await provider.addAccount({
@@ -2661,6 +2860,8 @@ function registerAccountTools(server, ctx) {
2661
2860
  outputSchema: completeAddAccountOutputSchema
2662
2861
  },
2663
2862
  async (args) => {
2863
+ const permErr = checkProvisioning(agentContext ?? null);
2864
+ if (permErr) return fail(permErr);
2664
2865
  const provider = registry.get(args.provider);
2665
2866
  if (!provider.completeAddAccount) {
2666
2867
  return fail(
@@ -2669,6 +2870,10 @@ function registerAccountTools(server, ctx) {
2669
2870
  }
2670
2871
  try {
2671
2872
  const res = await provider.completeAddAccount(args.handle);
2873
+ if (res.status === "ready" && res.account && agentContext && agentStore) {
2874
+ agentStore.assignAccount(agentContext.agentId, res.account.email).catch(() => {
2875
+ });
2876
+ }
2672
2877
  return ok(res, res);
2673
2878
  } catch (err) {
2674
2879
  return fail(errMsg(err));
@@ -2690,6 +2895,8 @@ function registerAccountTools(server, ctx) {
2690
2895
  },
2691
2896
  async (args) => {
2692
2897
  try {
2898
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
2899
+ if (accessErr) return fail(accessErr);
2693
2900
  const acct = store.getAccount(args.account);
2694
2901
  if (!acct)
2695
2902
  return fail(`no account registered for "${args.account}"`);
@@ -2708,11 +2915,14 @@ function registerAccountTools(server, ctx) {
2708
2915
  server.registerTool(
2709
2916
  "set_account_settings",
2710
2917
  {
2711
- description: "Set signature (HTML snippet) and/or style preferences for an account. Disabled in --read-only mode.",
2918
+ description: "Set signature (HTML snippet) and/or style preferences for an account. Use `signaturePath` to load a signature from a file (useful for signatures with base64 images). `signature` and `signaturePath` are mutually exclusive. Disabled in --read-only mode.",
2712
2919
  inputSchema: z2.object({
2713
2920
  account: z2.string().email(),
2714
2921
  signature: z2.string().optional().describe(
2715
- "HTML snippet \u2014 may contain formatting, images, links. Pass null to clear."
2922
+ "HTML snippet \u2014 may contain formatting, images, links. Pass an empty string to clear. Mutually exclusive with `signaturePath`."
2923
+ ),
2924
+ signaturePath: z2.string().optional().describe(
2925
+ "Path to a file containing the signature HTML. The file content is read and stored as the signature. Useful when the signature contains large base64 images. Mutually exclusive with `signature`."
2716
2926
  ),
2717
2927
  style: z2.object({
2718
2928
  fontFamily: z2.string().optional(),
@@ -2721,17 +2931,30 @@ function registerAccountTools(server, ctx) {
2721
2931
  }).optional().describe(
2722
2932
  "Font preferences applied to outgoing HTML emails. Pass null to clear."
2723
2933
  )
2724
- }),
2934
+ }).refine(
2935
+ (data) => !(data.signature !== void 0 && data.signaturePath),
2936
+ {
2937
+ message: "signature and signaturePath are mutually exclusive \u2014 use one or the other"
2938
+ }
2939
+ ),
2725
2940
  outputSchema: accountSettingsOutputSchema
2726
2941
  },
2727
2942
  async (args) => {
2728
2943
  try {
2944
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
2945
+ if (accessErr) return fail(accessErr);
2729
2946
  const acct = store.getAccount(args.account);
2730
2947
  if (!acct)
2731
2948
  return fail(`no account registered for "${args.account}"`);
2949
+ let resolvedSignature = acct.signature;
2950
+ if (args.signaturePath) {
2951
+ resolvedSignature = await fs4.readFile(args.signaturePath, "utf-8");
2952
+ } else if (args.signature !== void 0) {
2953
+ resolvedSignature = args.signature || void 0;
2954
+ }
2732
2955
  const updated = await store.upsertAccount({
2733
2956
  ...acct,
2734
- signature: args.signature ?? acct.signature,
2957
+ signature: resolvedSignature,
2735
2958
  style: args.style ?? acct.style
2736
2959
  });
2737
2960
  const data = {
@@ -2758,6 +2981,8 @@ function registerAccountTools(server, ctx) {
2758
2981
  outputSchema: removeAccountOutputSchema
2759
2982
  },
2760
2983
  async (args) => {
2984
+ const permErr = checkProvisioning(agentContext ?? null);
2985
+ if (permErr) return fail(permErr);
2761
2986
  const removed = await store.removeAccount(args.email);
2762
2987
  const data = { removed, email: args.email };
2763
2988
  return ok(data, data);
@@ -2797,7 +3022,7 @@ function selectBody(msg, format) {
2797
3022
 
2798
3023
  // src/tools/browse.ts
2799
3024
  function registerBrowseTools(server, ctx) {
2800
- const { registry, tools } = ctx;
3025
+ const { registry, tools, agentContext } = ctx;
2801
3026
  const emailListOutputSchema = z3.object({
2802
3027
  account: z3.string(),
2803
3028
  count: z3.number(),
@@ -2826,6 +3051,8 @@ function registerBrowseTools(server, ctx) {
2826
3051
  },
2827
3052
  async (args) => {
2828
3053
  try {
3054
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3055
+ if (accessErr) return fail(accessErr);
2829
3056
  const { provider, account } = registry.resolveByEmail(args.account);
2830
3057
  const { items, hasMore } = await provider.listEmails(account, {
2831
3058
  folder: args.folder,
@@ -2861,6 +3088,8 @@ function registerBrowseTools(server, ctx) {
2861
3088
  },
2862
3089
  async (args) => {
2863
3090
  try {
3091
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3092
+ if (accessErr) return fail(accessErr);
2864
3093
  const { provider, account } = registry.resolveByEmail(args.account);
2865
3094
  const items = await provider.searchEmails(account, args.query, {
2866
3095
  limit: args.limit
@@ -2909,6 +3138,8 @@ function registerBrowseTools(server, ctx) {
2909
3138
  },
2910
3139
  async (args) => {
2911
3140
  try {
3141
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3142
+ if (accessErr) return fail(accessErr);
2912
3143
  const { provider, account } = registry.resolveByEmail(args.account);
2913
3144
  const msg = await provider.readEmail(account, args.id);
2914
3145
  const format = args.format ?? "markdown";
@@ -2955,6 +3186,8 @@ function registerBrowseTools(server, ctx) {
2955
3186
  },
2956
3187
  async (args) => {
2957
3188
  try {
3189
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3190
+ if (accessErr) return fail(accessErr);
2958
3191
  const { provider, account } = registry.resolveByEmail(args.account);
2959
3192
  const res = await provider.readAttachment(
2960
3193
  account,
@@ -2973,7 +3206,7 @@ function registerBrowseTools(server, ctx) {
2973
3206
  // src/tools/folders.ts
2974
3207
  import { z as z4 } from "zod";
2975
3208
  function registerFolderTools(server, ctx) {
2976
- const { registry, tools } = ctx;
3209
+ const { registry, tools, agentContext } = ctx;
2977
3210
  const listFoldersOutputSchema = z4.object({
2978
3211
  account: z4.string(),
2979
3212
  count: z4.number(),
@@ -2994,6 +3227,8 @@ function registerFolderTools(server, ctx) {
2994
3227
  },
2995
3228
  async (args) => {
2996
3229
  try {
3230
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3231
+ if (accessErr) return fail(accessErr);
2997
3232
  const { provider, account } = registry.resolveByEmail(args.account);
2998
3233
  const items = await provider.listFolders(account, {
2999
3234
  parentFolderId: args.parentFolderId
@@ -3030,6 +3265,8 @@ function registerFolderTools(server, ctx) {
3030
3265
  },
3031
3266
  async (args) => {
3032
3267
  try {
3268
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3269
+ if (accessErr) return fail(accessErr);
3033
3270
  const { provider, account } = registry.resolveByEmail(args.account);
3034
3271
  const folder = await provider.createFolder(account, {
3035
3272
  displayName: args.displayName,
@@ -3060,6 +3297,8 @@ function registerFolderTools(server, ctx) {
3060
3297
  },
3061
3298
  async (args) => {
3062
3299
  try {
3300
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3301
+ if (accessErr) return fail(accessErr);
3063
3302
  const { provider, account } = registry.resolveByEmail(args.account);
3064
3303
  await provider.deleteFolder(account, args.folderId);
3065
3304
  const data = { deleted: true, id: args.folderId };
@@ -3088,6 +3327,8 @@ function registerFolderTools(server, ctx) {
3088
3327
  },
3089
3328
  async (args) => {
3090
3329
  try {
3330
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3331
+ if (accessErr) return fail(accessErr);
3091
3332
  const { provider, account } = registry.resolveByEmail(args.account);
3092
3333
  const folder = await provider.renameFolder(
3093
3334
  account,
@@ -3107,7 +3348,7 @@ function registerFolderTools(server, ctx) {
3107
3348
  // src/tools/organize.ts
3108
3349
  import { z as z5 } from "zod";
3109
3350
  function registerOrganizeTools(server, ctx) {
3110
- const { registry, tools } = ctx;
3351
+ const { registry, tools, agentContext } = ctx;
3111
3352
  async function moveToWellKnown(args, destination, resultKey) {
3112
3353
  const { provider, account } = registry.resolveByEmail(args.account);
3113
3354
  await provider.moveEmail(account, args.id, destination);
@@ -3139,6 +3380,8 @@ function registerOrganizeTools(server, ctx) {
3139
3380
  },
3140
3381
  async (args) => {
3141
3382
  try {
3383
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3384
+ if (accessErr) return fail(accessErr);
3142
3385
  return await moveToWellKnown(args, "archive", "archived");
3143
3386
  } catch (err) {
3144
3387
  return fail(errMsg(err));
@@ -3160,6 +3403,8 @@ function registerOrganizeTools(server, ctx) {
3160
3403
  },
3161
3404
  async (args) => {
3162
3405
  try {
3406
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3407
+ if (accessErr) return fail(accessErr);
3163
3408
  return await moveToWellKnown(args, "deleteditems", "trashed");
3164
3409
  } catch (err) {
3165
3410
  return fail(errMsg(err));
@@ -3188,6 +3433,8 @@ function registerOrganizeTools(server, ctx) {
3188
3433
  },
3189
3434
  async (args) => {
3190
3435
  try {
3436
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3437
+ if (accessErr) return fail(accessErr);
3191
3438
  const { provider, account } = registry.resolveByEmail(args.account);
3192
3439
  await provider.moveEmail(account, args.id, args.destination);
3193
3440
  const data = {
@@ -3221,6 +3468,8 @@ function registerOrganizeTools(server, ctx) {
3221
3468
  },
3222
3469
  async (args) => {
3223
3470
  try {
3471
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3472
+ if (accessErr) return fail(accessErr);
3224
3473
  return await markReadState(args, true);
3225
3474
  } catch (err) {
3226
3475
  return fail(errMsg(err));
@@ -3238,6 +3487,8 @@ function registerOrganizeTools(server, ctx) {
3238
3487
  },
3239
3488
  async (args) => {
3240
3489
  try {
3490
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3491
+ if (accessErr) return fail(accessErr);
3241
3492
  return await markReadState(args, false);
3242
3493
  } catch (err) {
3243
3494
  return fail(errMsg(err));
@@ -3250,7 +3501,7 @@ function registerOrganizeTools(server, ctx) {
3250
3501
  // src/tools/compose.ts
3251
3502
  import { z as z6 } from "zod";
3252
3503
  function registerComposeTools(server, ctx) {
3253
- const { store, registry, tools } = ctx;
3504
+ const { store, registry, tools, agentContext } = ctx;
3254
3505
  const sendEmailSchema = z6.object({
3255
3506
  account: z6.string().email(),
3256
3507
  to: z6.array(emailAddrSchema).min(1),
@@ -3258,7 +3509,9 @@ function registerComposeTools(server, ctx) {
3258
3509
  bcc: z6.array(emailAddrSchema).optional(),
3259
3510
  subject: z6.string(),
3260
3511
  body: z6.string(),
3261
- isHtml: z6.boolean().optional(),
3512
+ format: z6.enum(["html", "markdown"]).describe(
3513
+ "Body format. 'html' sends the body as-is (must be valid HTML). 'markdown' converts the body from Markdown to HTML for clean rendering on the recipient side."
3514
+ ),
3262
3515
  include_signature: z6.boolean().describe(
3263
3516
  "Whether to append the account's saved HTML signature to the email. If true, don't include a signature in the body param to avoid double signature. Returns an error if true but no signature is configured for this account."
3264
3517
  ),
@@ -3274,6 +3527,8 @@ function registerComposeTools(server, ctx) {
3274
3527
  });
3275
3528
  async function handleSendOrDraft(args, action, resultKey, toolName) {
3276
3529
  try {
3530
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3531
+ if (accessErr) return fail(accessErr);
3277
3532
  const { provider, account } = registry.resolveByEmail(args.account);
3278
3533
  if (args.include_signature && !account.signature) {
3279
3534
  return fail(
@@ -3282,7 +3537,7 @@ function registerComposeTools(server, ctx) {
3282
3537
  }
3283
3538
  const composed = composeBody({
3284
3539
  body: args.body,
3285
- isHtml: args.isHtml,
3540
+ format: args.format,
3286
3541
  signature: account.signature,
3287
3542
  style: account.style,
3288
3543
  includeSignature: args.include_signature
@@ -3362,7 +3617,9 @@ function registerComposeTools(server, ctx) {
3362
3617
  bcc: z6.array(emailAddrSchema).optional(),
3363
3618
  subject: z6.string().optional(),
3364
3619
  body: z6.string().optional(),
3365
- isHtml: z6.boolean().optional(),
3620
+ format: z6.enum(["html", "markdown"]).optional().describe(
3621
+ "Body format. Only meaningful when `body` is also provided. 'html' sends the body as-is (must be valid HTML). 'markdown' converts the body from Markdown to HTML for clean rendering on the recipient side."
3622
+ ),
3366
3623
  include_signature: z6.boolean().optional().describe(
3367
3624
  "Whether to re-apply the account's saved HTML signature to the body. If true, don't include a signature in the body param. Only meaningful when `body` is also provided. Returns an error if true but no signature is configured for this account."
3368
3625
  )
@@ -3383,6 +3640,8 @@ function registerComposeTools(server, ctx) {
3383
3640
  async (args) => {
3384
3641
  const a = args;
3385
3642
  try {
3643
+ const accessErr = checkAccountAccess(agentContext ?? null, a.account);
3644
+ if (accessErr) return fail(accessErr);
3386
3645
  const { provider, account } = registry.resolveByEmail(a.account);
3387
3646
  if (a.include_signature && !account.signature) {
3388
3647
  return fail(
@@ -3394,7 +3653,7 @@ function registerComposeTools(server, ctx) {
3394
3653
  if (a.body !== void 0) {
3395
3654
  const composed = composeBody({
3396
3655
  body: a.body,
3397
- isHtml: a.isHtml,
3656
+ format: a.format ?? "html",
3398
3657
  signature: account.signature,
3399
3658
  style: account.style,
3400
3659
  includeSignature: !!a.include_signature
@@ -3440,6 +3699,8 @@ function registerComposeTools(server, ctx) {
3440
3699
  },
3441
3700
  async (args) => {
3442
3701
  try {
3702
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3703
+ if (accessErr) return fail(accessErr);
3443
3704
  const { provider, account } = registry.resolveByEmail(args.account);
3444
3705
  const res = await provider.sendDraft(account, args.id);
3445
3706
  const data = { sent: true, id: res.id };
@@ -3475,6 +3736,8 @@ function registerComposeTools(server, ctx) {
3475
3736
  },
3476
3737
  async (args) => {
3477
3738
  try {
3739
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3740
+ if (accessErr) return fail(accessErr);
3478
3741
  const { provider, account } = registry.resolveByEmail(args.account);
3479
3742
  const res = await provider.addAttachmentToDraft(
3480
3743
  account,
@@ -3497,14 +3760,63 @@ function registerComposeTools(server, ctx) {
3497
3760
  }
3498
3761
  }
3499
3762
 
3763
+ // src/tools/notifications.ts
3764
+ import { z as z7 } from "zod";
3765
+ function registerNotificationTools(server, ctx) {
3766
+ const { tools, notificationBuffer, agentContext } = ctx;
3767
+ const notifyOutputSchema = z7.object({
3768
+ count: z7.number(),
3769
+ items: z7.array(
3770
+ z7.object({
3771
+ type: z7.enum(["new_emails", "auth_failure"]),
3772
+ account: z7.string(),
3773
+ emails: z7.array(z7.unknown()).optional(),
3774
+ error: z7.string().optional(),
3775
+ timestamp: z7.string()
3776
+ })
3777
+ )
3778
+ });
3779
+ if (shouldRegister("check_notifications", tools)) {
3780
+ server.registerTool(
3781
+ "check_notifications",
3782
+ {
3783
+ description: "Check for pending email watch notifications. Returns new-email alerts and auth-failure warnings that the inbox watcher has accumulated since the last call. Drains the notification buffer on read.",
3784
+ inputSchema: z7.object({}),
3785
+ outputSchema: notifyOutputSchema
3786
+ },
3787
+ async () => {
3788
+ try {
3789
+ const pending = notificationBuffer.splice(0);
3790
+ const filtered = agentContext ? pending.filter(
3791
+ (n) => agentContext.accounts.some(
3792
+ (a) => a.toLowerCase() === n.account.toLowerCase()
3793
+ )
3794
+ ) : pending;
3795
+ const data = { count: filtered.length, items: filtered };
3796
+ return ok(data, data);
3797
+ } catch (err) {
3798
+ return fail(errMsg(err));
3799
+ }
3800
+ }
3801
+ );
3802
+ }
3803
+ }
3804
+
3500
3805
  // src/tools/index.ts
3501
3806
  function registerTools(server, opts) {
3502
- const { store, registry, tools } = opts;
3503
- registerAccountTools(server, { store, registry, tools });
3504
- registerBrowseTools(server, { registry, tools });
3505
- registerFolderTools(server, { registry, tools });
3506
- registerOrganizeTools(server, { registry, tools });
3507
- registerComposeTools(server, { store, registry, tools });
3807
+ const { store, registry, tools, agentContext, agentStore } = opts;
3808
+ registerAccountTools(server, { store, registry, tools, agentContext, agentStore });
3809
+ registerBrowseTools(server, { registry, tools, agentContext });
3810
+ registerFolderTools(server, { registry, tools, agentContext });
3811
+ registerOrganizeTools(server, { registry, tools, agentContext });
3812
+ registerComposeTools(server, { store, registry, tools, agentContext });
3813
+ if (opts.notificationBuffer) {
3814
+ registerNotificationTools(server, {
3815
+ tools,
3816
+ notificationBuffer: opts.notificationBuffer,
3817
+ agentContext
3818
+ });
3819
+ }
3508
3820
  }
3509
3821
 
3510
3822
  // src/version.ts
@@ -3512,33 +3824,38 @@ var VERSION = "0.4.1";
3512
3824
 
3513
3825
  // src/config.ts
3514
3826
  import { readFileSync } from "fs";
3515
- import { z as z7 } from "zod";
3516
- var httpConfigSchema = z7.object({
3517
- enabled: z7.boolean().default(false),
3518
- port: z7.number().int().min(1).max(65535).default(3e3),
3519
- host: z7.string().default("127.0.0.1")
3827
+ import { z as z8 } from "zod";
3828
+ var httpConfigSchema = z8.object({
3829
+ enabled: z8.boolean().default(false),
3830
+ port: z8.number().int().min(1).max(65535).default(3e3),
3831
+ host: z8.string().default("127.0.0.1")
3520
3832
  });
3521
- var toolsConfigSchema = z7.object({
3522
- disabled: z7.array(z7.string()).optional(),
3523
- enabled: z7.array(z7.string()).optional()
3833
+ var toolsConfigSchema = z8.object({
3834
+ disabled: z8.array(z8.string()).optional(),
3835
+ enabled: z8.array(z8.string()).optional()
3524
3836
  });
3525
- var outlookProviderSchema = z7.object({
3526
- clientId: z7.string().optional(),
3527
- tenantId: z7.string().optional()
3837
+ var outlookProviderSchema = z8.object({
3838
+ clientId: z8.string().optional(),
3839
+ tenantId: z8.string().optional()
3528
3840
  });
3529
- var gmailProviderSchema = z7.object({
3530
- clientId: z7.string().optional(),
3531
- clientSecret: z7.string().optional()
3841
+ var gmailProviderSchema = z8.object({
3842
+ clientId: z8.string().optional(),
3843
+ clientSecret: z8.string().optional()
3532
3844
  });
3533
- var providersConfigSchema = z7.object({
3845
+ var providersConfigSchema = z8.object({
3534
3846
  outlook: outlookProviderSchema.optional(),
3535
3847
  gmail: gmailProviderSchema.optional()
3536
3848
  });
3537
- var rawConfigSchema = z7.object({
3538
- dataDir: z7.string().optional(),
3849
+ var watchConfigSchema = z8.object({
3850
+ enabled: z8.boolean().default(true),
3851
+ pollIntervalSeconds: z8.number().int().min(10).max(3600).default(60)
3852
+ });
3853
+ var rawConfigSchema = z8.object({
3854
+ dataDir: z8.string().optional(),
3539
3855
  http: httpConfigSchema.optional(),
3540
3856
  tools: toolsConfigSchema.optional(),
3541
- providers: providersConfigSchema.optional()
3857
+ providers: providersConfigSchema.optional(),
3858
+ watch: watchConfigSchema.optional()
3542
3859
  });
3543
3860
  var KNOWN_TOOLS = [
3544
3861
  "list_accounts",
@@ -3564,7 +3881,8 @@ var KNOWN_TOOLS = [
3564
3881
  "draft_email",
3565
3882
  "edit_draft",
3566
3883
  "send_draft",
3567
- "add_attachment_to_draft"
3884
+ "add_attachment_to_draft",
3885
+ "check_notifications"
3568
3886
  ];
3569
3887
  var ENV_VAR_RE = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
3570
3888
  function resolveEnvVars(value) {
@@ -3630,7 +3948,9 @@ function loadConfig(configPath, cliOverrides = {}) {
3630
3948
  dataDir: cliOverrides.dataDir ?? parsed.dataDir ?? process.env.HYPERMAIL_MCP_DATA_DIR,
3631
3949
  http,
3632
3950
  tools: parsed.tools ? { disabled: parsed.tools.disabled, enabled: parsed.tools.enabled } : void 0,
3633
- providers: parsed.providers
3951
+ providers: parsed.providers,
3952
+ watch: parsed.watch,
3953
+ agentsConfigPath: cliOverrides.agentsConfig ?? process.env.HYPERMAIL_AGENTS_CONFIG
3634
3954
  };
3635
3955
  }
3636
3956
  function resolveTools(config) {
@@ -3643,25 +3963,284 @@ function resolveTools(config) {
3643
3963
  };
3644
3964
  }
3645
3965
 
3966
+ // src/watcher/manager.ts
3967
+ var WatcherManager = class {
3968
+ opts;
3969
+ timers = [];
3970
+ running = false;
3971
+ /** Per-account inflight guards to prevent overlapping polls. */
3972
+ inflight = /* @__PURE__ */ new Map();
3973
+ constructor(opts) {
3974
+ this.opts = opts;
3975
+ }
3976
+ start() {
3977
+ if (this.running) return;
3978
+ this.running = true;
3979
+ let accounts = this.opts.store.listAccounts();
3980
+ if (this.opts.accountFilter) {
3981
+ const filter = new Set(this.opts.accountFilter.map((e) => e.toLowerCase()));
3982
+ accounts = accounts.filter((a) => filter.has(a.email.toLowerCase()));
3983
+ }
3984
+ for (const account of accounts) {
3985
+ this.schedulePoll(account);
3986
+ }
3987
+ }
3988
+ stop() {
3989
+ this.running = false;
3990
+ for (const t of this.timers) clearInterval(t);
3991
+ this.timers = [];
3992
+ }
3993
+ // ── internals ──
3994
+ schedulePoll(account) {
3995
+ this.pollAccount(account).catch(() => {
3996
+ });
3997
+ const timer = setInterval(() => {
3998
+ if (!this.running) return;
3999
+ this.pollAccount(account).catch(() => {
4000
+ });
4001
+ }, this.opts.pollIntervalSeconds * 1e3);
4002
+ this.timers.push(timer);
4003
+ }
4004
+ async pollAccount(account) {
4005
+ const key = account.email.toLowerCase();
4006
+ if (this.inflight.get(key)) return;
4007
+ this.inflight.set(key, true);
4008
+ try {
4009
+ const { provider } = this.opts.registry.resolveByEmail(account.email);
4010
+ const lastSeen = account.lastSeenAt;
4011
+ const limit = 25;
4012
+ let skip = 0;
4013
+ const newEmails = [];
4014
+ let newestTimestamp = lastSeen ?? "";
4015
+ let hitBoundary = false;
4016
+ while (true) {
4017
+ const { items, hasMore } = await provider.listEmails(account, {
4018
+ folder: "inbox",
4019
+ limit,
4020
+ skip
4021
+ });
4022
+ for (const item of items) {
4023
+ if (!item.receivedAt) continue;
4024
+ if (lastSeen && item.receivedAt <= lastSeen) {
4025
+ hitBoundary = true;
4026
+ break;
4027
+ }
4028
+ newEmails.push(item);
4029
+ if (item.receivedAt > newestTimestamp) {
4030
+ newestTimestamp = item.receivedAt;
4031
+ }
4032
+ }
4033
+ if (hitBoundary || !hasMore) break;
4034
+ skip += limit;
4035
+ }
4036
+ if (!lastSeen) {
4037
+ if (newEmails.length > 0) {
4038
+ newestTimestamp = newEmails[0].receivedAt;
4039
+ }
4040
+ } else if (newEmails.length > 0) {
4041
+ this.enqueue({
4042
+ type: "new_emails",
4043
+ account: account.email,
4044
+ emails: newEmails,
4045
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4046
+ });
4047
+ }
4048
+ if (newestTimestamp !== (lastSeen ?? "")) {
4049
+ await this.opts.store.upsertAccount({
4050
+ ...account,
4051
+ lastSeenAt: newestTimestamp || void 0
4052
+ });
4053
+ }
4054
+ } catch (err) {
4055
+ this.enqueue({
4056
+ type: "auth_failure",
4057
+ account: account.email,
4058
+ error: err instanceof Error ? err.message : String(err),
4059
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
4060
+ });
4061
+ } finally {
4062
+ this.inflight.delete(key);
4063
+ }
4064
+ }
4065
+ enqueue(notification) {
4066
+ this.opts.buffer.push(notification);
4067
+ try {
4068
+ this.opts.onNotification(notification);
4069
+ } catch {
4070
+ }
4071
+ }
4072
+ };
4073
+
4074
+ // src/config/agents-config.ts
4075
+ import { readFileSync as readFileSync2, watch } from "fs";
4076
+ import { load as loadYaml } from "js-yaml";
4077
+ import { z as z9 } from "zod";
4078
+ var agentDefSchema = z9.object({
4079
+ id: z9.string().min(1).regex(
4080
+ /^[a-z0-9_-]+$/,
4081
+ "agent id must contain only lowercase letters, digits, hyphens, and underscores"
4082
+ ),
4083
+ api_key: z9.string().min(1).regex(
4084
+ /^hm_sk_[a-f0-9]{64}$/,
4085
+ "api_key must match hm_sk_ prefix + 64 hex chars (use `hypermail-mcp generate-key`)"
4086
+ ),
4087
+ name: z9.string().min(1),
4088
+ accounts: z9.array(z9.string().email()).optional().default([]),
4089
+ provisioning: z9.boolean().optional().default(false)
4090
+ });
4091
+ var emailAccountDefSchema = z9.object({
4092
+ provider: z9.enum(["outlook", "imap", "gmail"]),
4093
+ display_name: z9.string().optional()
4094
+ });
4095
+ var agentsConfigSchema = z9.object({
4096
+ agents: z9.array(agentDefSchema).optional().default([]),
4097
+ email_accounts: z9.record(z9.string().email(), emailAccountDefSchema).optional().default({})
4098
+ });
4099
+ function loadAgentsConfig(configPath) {
4100
+ let raw;
4101
+ try {
4102
+ raw = loadYaml(readFileSync2(configPath, "utf-8"));
4103
+ } catch (err) {
4104
+ if (err instanceof Error && "code" in err && err.code === "ENOENT") {
4105
+ throw new Error(
4106
+ `Agents config file not found: ${configPath}. Create an agents.yaml with at least one agent to enable HTTP multi-tenant mode.`
4107
+ );
4108
+ }
4109
+ const detail = err instanceof Error ? err.message : String(err);
4110
+ throw new Error(`Failed to parse agents config "${configPath}": ${detail}`);
4111
+ }
4112
+ const parsed = agentsConfigSchema.parse(raw ?? {});
4113
+ const ids = /* @__PURE__ */ new Set();
4114
+ for (const a of parsed.agents) {
4115
+ if (ids.has(a.id)) {
4116
+ throw new Error(`Duplicate agent id "${a.id}" in agents config`);
4117
+ }
4118
+ ids.add(a.id);
4119
+ }
4120
+ return {
4121
+ agents: parsed.agents.map((a) => ({
4122
+ id: a.id,
4123
+ api_key: a.api_key,
4124
+ name: a.name,
4125
+ accounts: a.accounts,
4126
+ provisioning: a.provisioning
4127
+ })),
4128
+ email_accounts: parsed.email_accounts
4129
+ };
4130
+ }
4131
+ async function syncAgentsToStore(config, store) {
4132
+ const configAgentIds = new Set(config.agents.map((a) => a.id));
4133
+ const storedAgents = store.listAgents();
4134
+ for (const def of config.agents) {
4135
+ await store.upsertAgent({
4136
+ id: def.id,
4137
+ plaintextApiKey: def.api_key,
4138
+ name: def.name,
4139
+ accounts: def.accounts,
4140
+ provisioning: def.provisioning
4141
+ });
4142
+ }
4143
+ const removed = [];
4144
+ for (const stored of storedAgents) {
4145
+ if (!configAgentIds.has(stored.id)) {
4146
+ await store.removeAgent(stored.id);
4147
+ removed.push(stored.id);
4148
+ }
4149
+ }
4150
+ return removed;
4151
+ }
4152
+ function watchAgentsConfig(configPath, store, onChange, onError) {
4153
+ let timer = null;
4154
+ let watcher = null;
4155
+ const reload = async () => {
4156
+ try {
4157
+ const config = loadAgentsConfig(configPath);
4158
+ const removed = await syncAgentsToStore(config, store);
4159
+ onChange(removed);
4160
+ } catch (err) {
4161
+ onError(err instanceof Error ? err : new Error(String(err)));
4162
+ }
4163
+ };
4164
+ reload().catch((err) => onError(err));
4165
+ try {
4166
+ watcher = watch(configPath, (_eventType) => {
4167
+ if (timer) clearTimeout(timer);
4168
+ timer = setTimeout(() => {
4169
+ timer = null;
4170
+ reload().catch((err) => onError(err));
4171
+ }, 200);
4172
+ });
4173
+ } catch (err) {
4174
+ }
4175
+ return {
4176
+ close() {
4177
+ if (timer) clearTimeout(timer);
4178
+ if (watcher) watcher.close();
4179
+ }
4180
+ };
4181
+ }
4182
+
3646
4183
  // src/server.ts
3647
4184
  async function startServer(opts) {
3648
4185
  const { config } = opts;
3649
4186
  const store = await AccountStore.open({ dataDir: config.dataDir });
3650
4187
  const registry = buildRegistry({ store, providers: config.providers });
3651
4188
  const tools = resolveTools(config);
3652
- const server = new McpServer(
3653
- { name: "hypermail-mcp", version: VERSION },
3654
- { capabilities: { tools: {}, logging: {} } }
3655
- );
3656
- registerTools(server, { store, registry, tools });
4189
+ const notificationBuffer = config.http.enabled ? [] : void 0;
4190
+ let agentStoreForFactory;
4191
+ const createServer = (agentContext = null) => {
4192
+ const s = new McpServer(
4193
+ { name: "hypermail-mcp", version: VERSION },
4194
+ { capabilities: { tools: {}, logging: {} } }
4195
+ );
4196
+ registerTools(s, { store, registry, tools, notificationBuffer, agentContext, agentStore: agentStoreForFactory });
4197
+ return s;
4198
+ };
3657
4199
  if (config.http.enabled) {
3658
- await startHttp(server, config.http.host, config.http.port);
4200
+ let liveReloadHandle;
4201
+ if (config.agentsConfigPath) {
4202
+ agentStoreForFactory = await AgentStore.open({ dataDir: config.dataDir });
4203
+ liveReloadHandle = watchAgentsConfig(
4204
+ path4.resolve(config.agentsConfigPath),
4205
+ agentStoreForFactory,
4206
+ (_removedIds) => {
4207
+ },
4208
+ (err) => {
4209
+ console.error("[hypermail-mcp] agents.yaml reload error:", err.message);
4210
+ }
4211
+ );
4212
+ }
4213
+ const notifyTargets = /* @__PURE__ */ new Set();
4214
+ const watcher = new WatcherManager({
4215
+ registry,
4216
+ store,
4217
+ pollIntervalSeconds: config.watch?.pollIntervalSeconds ?? 60,
4218
+ onNotification: (notification) => {
4219
+ for (const fn of notifyTargets) {
4220
+ fn(notification);
4221
+ }
4222
+ },
4223
+ buffer: notificationBuffer
4224
+ });
4225
+ watcher.start();
4226
+ await startHttp(
4227
+ createServer,
4228
+ config.http.host,
4229
+ config.http.port,
4230
+ notifyTargets,
4231
+ agentStoreForFactory
4232
+ );
4233
+ if (liveReloadHandle) {
4234
+ process.on("SIGINT", () => liveReloadHandle.close());
4235
+ process.on("SIGTERM", () => liveReloadHandle.close());
4236
+ }
3659
4237
  } else {
4238
+ const server = createServer();
3660
4239
  const transport = new StdioServerTransport();
3661
4240
  await server.connect(transport);
3662
4241
  }
3663
4242
  }
3664
- async function startHttp(server, host, port) {
4243
+ async function startHttp(createServer, host, port, notifyTargets, agentStore) {
3665
4244
  const sessions = /* @__PURE__ */ new Map();
3666
4245
  const http = createHttpServer(async (req, res) => {
3667
4246
  try {
@@ -3671,18 +4250,59 @@ async function startHttp(server, host, port) {
3671
4250
  return;
3672
4251
  }
3673
4252
  const sessionId = req.headers["mcp-session-id"] ?? void 0;
3674
- let transport = sessionId ? sessions.get(sessionId) : void 0;
3675
- if (!transport) {
3676
- transport = new StreamableHTTPServerTransport({
4253
+ let session = sessionId ? sessions.get(sessionId) : void 0;
4254
+ if (!session) {
4255
+ let agentContext = null;
4256
+ if (agentStore) {
4257
+ const apiKey = req.headers["x-api-key"]?.trim();
4258
+ if (!apiKey) {
4259
+ res.statusCode = 401;
4260
+ res.setHeader("Content-Type", "application/json");
4261
+ res.end(JSON.stringify({ error: "Missing x-api-key header" }));
4262
+ return;
4263
+ }
4264
+ const agent = agentStore.findAgentByApiKey(apiKey);
4265
+ if (!agent) {
4266
+ res.statusCode = 401;
4267
+ res.setHeader("Content-Type", "application/json");
4268
+ res.end(JSON.stringify({ error: "Invalid API key" }));
4269
+ return;
4270
+ }
4271
+ agentContext = {
4272
+ agentId: agent.id,
4273
+ accounts: agent.accounts,
4274
+ provisioning: agent.provisioning
4275
+ };
4276
+ }
4277
+ const server = createServer(agentContext);
4278
+ const transport = new StreamableHTTPServerTransport({
3677
4279
  sessionIdGenerator: () => randomUUID6(),
3678
4280
  onsessioninitialized: (sid) => {
3679
- sessions.set(sid, transport);
4281
+ sessions.set(sid, { transport, server, agentContext });
4282
+ const agentAccounts = agentContext ? new Set(agentContext.accounts.map((a) => a.toLowerCase())) : null;
4283
+ const notifyFn = (n) => {
4284
+ if (agentAccounts && !agentAccounts.has(n.account.toLowerCase())) {
4285
+ return;
4286
+ }
4287
+ server.server.notification({
4288
+ method: "notifications/message",
4289
+ params: {
4290
+ level: n.type === "new_emails" ? "notice" : "warning",
4291
+ logger: "hypermail-watch",
4292
+ data: n
4293
+ }
4294
+ }).catch(() => {
4295
+ });
4296
+ };
4297
+ notifyTargets.add(notifyFn);
4298
+ transport.onclose = () => {
4299
+ if (transport.sessionId) sessions.delete(transport.sessionId);
4300
+ notifyTargets.delete(notifyFn);
4301
+ };
3680
4302
  }
3681
4303
  });
3682
- transport.onclose = () => {
3683
- if (transport.sessionId) sessions.delete(transport.sessionId);
3684
- };
3685
4304
  await server.connect(transport);
4305
+ session = { transport, server, agentContext };
3686
4306
  }
3687
4307
  let body = void 0;
3688
4308
  if (req.method === "POST" || req.method === "DELETE") {
@@ -3691,7 +4311,7 @@ async function startHttp(server, host, port) {
3691
4311
  const raw = Buffer.concat(chunks).toString("utf8");
3692
4312
  body = raw ? JSON.parse(raw) : void 0;
3693
4313
  }
3694
- await transport.handleRequest(req, res, body);
4314
+ await session.transport.handleRequest(req, res, body);
3695
4315
  } catch (err) {
3696
4316
  console.error("[hypermail-mcp] http error:", err);
3697
4317
  if (!res.headersSent) {
@@ -3730,6 +4350,9 @@ function parseArgs(argv) {
3730
4350
  case "--config":
3731
4351
  out.config = String(argv[++i] ?? "");
3732
4352
  break;
4353
+ case "--agents-config":
4354
+ out.agentsConfig = String(argv[++i] ?? "");
4355
+ break;
3733
4356
  case "-h":
3734
4357
  case "--help":
3735
4358
  out.help = true;
@@ -3774,7 +4397,13 @@ Example hypermail-config.json:
3774
4397
  process.stdout.write(msg);
3775
4398
  }
3776
4399
  async function main() {
3777
- const opts = parseArgs(process.argv.slice(2));
4400
+ const rawArgs = process.argv.slice(2);
4401
+ if (rawArgs[0] === "generate-key") {
4402
+ const key = `hm_sk_${randomBytes2(32).toString("hex")}`;
4403
+ process.stdout.write(key + "\n");
4404
+ return;
4405
+ }
4406
+ const opts = parseArgs(rawArgs);
3778
4407
  if (opts.help) {
3779
4408
  printHelp();
3780
4409
  return;
@@ -3783,7 +4412,8 @@ async function main() {
3783
4412
  http: opts.http,
3784
4413
  port: opts.port,
3785
4414
  host: opts.host,
3786
- dataDir: opts.dataDir
4415
+ dataDir: opts.dataDir,
4416
+ agentsConfig: opts.agentsConfig
3787
4417
  });
3788
4418
  await startServer({ config });
3789
4419
  }