hypermail-mcp 0.5.0 → 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 });
@@ -2574,9 +2754,27 @@ function shouldRegister(name, tools) {
2574
2754
  }
2575
2755
 
2576
2756
  // src/tools/accounts.ts
2757
+ import { promises as fs4 } from "fs";
2577
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
2578
2776
  function registerAccountTools(server, ctx) {
2579
- const { store, registry, tools } = ctx;
2777
+ const { store, registry, tools, agentContext, agentStore } = ctx;
2580
2778
  const listAccountsOutputSchema = z2.object({
2581
2779
  accounts: z2.array(accountSummaryOutputSchema)
2582
2780
  });
@@ -2630,6 +2828,8 @@ function registerAccountTools(server, ctx) {
2630
2828
  outputSchema: addAccountOutputSchema
2631
2829
  },
2632
2830
  async (args) => {
2831
+ const permErr = checkProvisioning(agentContext ?? null);
2832
+ if (permErr) return fail(permErr);
2633
2833
  const provider = registry.get(args.provider);
2634
2834
  try {
2635
2835
  const res = await provider.addAccount({
@@ -2660,6 +2860,8 @@ function registerAccountTools(server, ctx) {
2660
2860
  outputSchema: completeAddAccountOutputSchema
2661
2861
  },
2662
2862
  async (args) => {
2863
+ const permErr = checkProvisioning(agentContext ?? null);
2864
+ if (permErr) return fail(permErr);
2663
2865
  const provider = registry.get(args.provider);
2664
2866
  if (!provider.completeAddAccount) {
2665
2867
  return fail(
@@ -2668,6 +2870,10 @@ function registerAccountTools(server, ctx) {
2668
2870
  }
2669
2871
  try {
2670
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
+ }
2671
2877
  return ok(res, res);
2672
2878
  } catch (err) {
2673
2879
  return fail(errMsg(err));
@@ -2689,6 +2895,8 @@ function registerAccountTools(server, ctx) {
2689
2895
  },
2690
2896
  async (args) => {
2691
2897
  try {
2898
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
2899
+ if (accessErr) return fail(accessErr);
2692
2900
  const acct = store.getAccount(args.account);
2693
2901
  if (!acct)
2694
2902
  return fail(`no account registered for "${args.account}"`);
@@ -2707,11 +2915,14 @@ function registerAccountTools(server, ctx) {
2707
2915
  server.registerTool(
2708
2916
  "set_account_settings",
2709
2917
  {
2710
- 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.",
2711
2919
  inputSchema: z2.object({
2712
2920
  account: z2.string().email(),
2713
2921
  signature: z2.string().optional().describe(
2714
- "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`."
2715
2926
  ),
2716
2927
  style: z2.object({
2717
2928
  fontFamily: z2.string().optional(),
@@ -2720,17 +2931,30 @@ function registerAccountTools(server, ctx) {
2720
2931
  }).optional().describe(
2721
2932
  "Font preferences applied to outgoing HTML emails. Pass null to clear."
2722
2933
  )
2723
- }),
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
+ ),
2724
2940
  outputSchema: accountSettingsOutputSchema
2725
2941
  },
2726
2942
  async (args) => {
2727
2943
  try {
2944
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
2945
+ if (accessErr) return fail(accessErr);
2728
2946
  const acct = store.getAccount(args.account);
2729
2947
  if (!acct)
2730
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
+ }
2731
2955
  const updated = await store.upsertAccount({
2732
2956
  ...acct,
2733
- signature: args.signature ?? acct.signature,
2957
+ signature: resolvedSignature,
2734
2958
  style: args.style ?? acct.style
2735
2959
  });
2736
2960
  const data = {
@@ -2757,6 +2981,8 @@ function registerAccountTools(server, ctx) {
2757
2981
  outputSchema: removeAccountOutputSchema
2758
2982
  },
2759
2983
  async (args) => {
2984
+ const permErr = checkProvisioning(agentContext ?? null);
2985
+ if (permErr) return fail(permErr);
2760
2986
  const removed = await store.removeAccount(args.email);
2761
2987
  const data = { removed, email: args.email };
2762
2988
  return ok(data, data);
@@ -2796,7 +3022,7 @@ function selectBody(msg, format) {
2796
3022
 
2797
3023
  // src/tools/browse.ts
2798
3024
  function registerBrowseTools(server, ctx) {
2799
- const { registry, tools } = ctx;
3025
+ const { registry, tools, agentContext } = ctx;
2800
3026
  const emailListOutputSchema = z3.object({
2801
3027
  account: z3.string(),
2802
3028
  count: z3.number(),
@@ -2825,6 +3051,8 @@ function registerBrowseTools(server, ctx) {
2825
3051
  },
2826
3052
  async (args) => {
2827
3053
  try {
3054
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3055
+ if (accessErr) return fail(accessErr);
2828
3056
  const { provider, account } = registry.resolveByEmail(args.account);
2829
3057
  const { items, hasMore } = await provider.listEmails(account, {
2830
3058
  folder: args.folder,
@@ -2860,6 +3088,8 @@ function registerBrowseTools(server, ctx) {
2860
3088
  },
2861
3089
  async (args) => {
2862
3090
  try {
3091
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3092
+ if (accessErr) return fail(accessErr);
2863
3093
  const { provider, account } = registry.resolveByEmail(args.account);
2864
3094
  const items = await provider.searchEmails(account, args.query, {
2865
3095
  limit: args.limit
@@ -2908,6 +3138,8 @@ function registerBrowseTools(server, ctx) {
2908
3138
  },
2909
3139
  async (args) => {
2910
3140
  try {
3141
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3142
+ if (accessErr) return fail(accessErr);
2911
3143
  const { provider, account } = registry.resolveByEmail(args.account);
2912
3144
  const msg = await provider.readEmail(account, args.id);
2913
3145
  const format = args.format ?? "markdown";
@@ -2954,6 +3186,8 @@ function registerBrowseTools(server, ctx) {
2954
3186
  },
2955
3187
  async (args) => {
2956
3188
  try {
3189
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3190
+ if (accessErr) return fail(accessErr);
2957
3191
  const { provider, account } = registry.resolveByEmail(args.account);
2958
3192
  const res = await provider.readAttachment(
2959
3193
  account,
@@ -2972,7 +3206,7 @@ function registerBrowseTools(server, ctx) {
2972
3206
  // src/tools/folders.ts
2973
3207
  import { z as z4 } from "zod";
2974
3208
  function registerFolderTools(server, ctx) {
2975
- const { registry, tools } = ctx;
3209
+ const { registry, tools, agentContext } = ctx;
2976
3210
  const listFoldersOutputSchema = z4.object({
2977
3211
  account: z4.string(),
2978
3212
  count: z4.number(),
@@ -2993,6 +3227,8 @@ function registerFolderTools(server, ctx) {
2993
3227
  },
2994
3228
  async (args) => {
2995
3229
  try {
3230
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3231
+ if (accessErr) return fail(accessErr);
2996
3232
  const { provider, account } = registry.resolveByEmail(args.account);
2997
3233
  const items = await provider.listFolders(account, {
2998
3234
  parentFolderId: args.parentFolderId
@@ -3029,6 +3265,8 @@ function registerFolderTools(server, ctx) {
3029
3265
  },
3030
3266
  async (args) => {
3031
3267
  try {
3268
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3269
+ if (accessErr) return fail(accessErr);
3032
3270
  const { provider, account } = registry.resolveByEmail(args.account);
3033
3271
  const folder = await provider.createFolder(account, {
3034
3272
  displayName: args.displayName,
@@ -3059,6 +3297,8 @@ function registerFolderTools(server, ctx) {
3059
3297
  },
3060
3298
  async (args) => {
3061
3299
  try {
3300
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3301
+ if (accessErr) return fail(accessErr);
3062
3302
  const { provider, account } = registry.resolveByEmail(args.account);
3063
3303
  await provider.deleteFolder(account, args.folderId);
3064
3304
  const data = { deleted: true, id: args.folderId };
@@ -3087,6 +3327,8 @@ function registerFolderTools(server, ctx) {
3087
3327
  },
3088
3328
  async (args) => {
3089
3329
  try {
3330
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3331
+ if (accessErr) return fail(accessErr);
3090
3332
  const { provider, account } = registry.resolveByEmail(args.account);
3091
3333
  const folder = await provider.renameFolder(
3092
3334
  account,
@@ -3106,7 +3348,7 @@ function registerFolderTools(server, ctx) {
3106
3348
  // src/tools/organize.ts
3107
3349
  import { z as z5 } from "zod";
3108
3350
  function registerOrganizeTools(server, ctx) {
3109
- const { registry, tools } = ctx;
3351
+ const { registry, tools, agentContext } = ctx;
3110
3352
  async function moveToWellKnown(args, destination, resultKey) {
3111
3353
  const { provider, account } = registry.resolveByEmail(args.account);
3112
3354
  await provider.moveEmail(account, args.id, destination);
@@ -3138,6 +3380,8 @@ function registerOrganizeTools(server, ctx) {
3138
3380
  },
3139
3381
  async (args) => {
3140
3382
  try {
3383
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3384
+ if (accessErr) return fail(accessErr);
3141
3385
  return await moveToWellKnown(args, "archive", "archived");
3142
3386
  } catch (err) {
3143
3387
  return fail(errMsg(err));
@@ -3159,6 +3403,8 @@ function registerOrganizeTools(server, ctx) {
3159
3403
  },
3160
3404
  async (args) => {
3161
3405
  try {
3406
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3407
+ if (accessErr) return fail(accessErr);
3162
3408
  return await moveToWellKnown(args, "deleteditems", "trashed");
3163
3409
  } catch (err) {
3164
3410
  return fail(errMsg(err));
@@ -3187,6 +3433,8 @@ function registerOrganizeTools(server, ctx) {
3187
3433
  },
3188
3434
  async (args) => {
3189
3435
  try {
3436
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3437
+ if (accessErr) return fail(accessErr);
3190
3438
  const { provider, account } = registry.resolveByEmail(args.account);
3191
3439
  await provider.moveEmail(account, args.id, args.destination);
3192
3440
  const data = {
@@ -3220,6 +3468,8 @@ function registerOrganizeTools(server, ctx) {
3220
3468
  },
3221
3469
  async (args) => {
3222
3470
  try {
3471
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3472
+ if (accessErr) return fail(accessErr);
3223
3473
  return await markReadState(args, true);
3224
3474
  } catch (err) {
3225
3475
  return fail(errMsg(err));
@@ -3237,6 +3487,8 @@ function registerOrganizeTools(server, ctx) {
3237
3487
  },
3238
3488
  async (args) => {
3239
3489
  try {
3490
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3491
+ if (accessErr) return fail(accessErr);
3240
3492
  return await markReadState(args, false);
3241
3493
  } catch (err) {
3242
3494
  return fail(errMsg(err));
@@ -3249,7 +3501,7 @@ function registerOrganizeTools(server, ctx) {
3249
3501
  // src/tools/compose.ts
3250
3502
  import { z as z6 } from "zod";
3251
3503
  function registerComposeTools(server, ctx) {
3252
- const { store, registry, tools } = ctx;
3504
+ const { store, registry, tools, agentContext } = ctx;
3253
3505
  const sendEmailSchema = z6.object({
3254
3506
  account: z6.string().email(),
3255
3507
  to: z6.array(emailAddrSchema).min(1),
@@ -3275,6 +3527,8 @@ function registerComposeTools(server, ctx) {
3275
3527
  });
3276
3528
  async function handleSendOrDraft(args, action, resultKey, toolName) {
3277
3529
  try {
3530
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3531
+ if (accessErr) return fail(accessErr);
3278
3532
  const { provider, account } = registry.resolveByEmail(args.account);
3279
3533
  if (args.include_signature && !account.signature) {
3280
3534
  return fail(
@@ -3386,6 +3640,8 @@ function registerComposeTools(server, ctx) {
3386
3640
  async (args) => {
3387
3641
  const a = args;
3388
3642
  try {
3643
+ const accessErr = checkAccountAccess(agentContext ?? null, a.account);
3644
+ if (accessErr) return fail(accessErr);
3389
3645
  const { provider, account } = registry.resolveByEmail(a.account);
3390
3646
  if (a.include_signature && !account.signature) {
3391
3647
  return fail(
@@ -3443,6 +3699,8 @@ function registerComposeTools(server, ctx) {
3443
3699
  },
3444
3700
  async (args) => {
3445
3701
  try {
3702
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3703
+ if (accessErr) return fail(accessErr);
3446
3704
  const { provider, account } = registry.resolveByEmail(args.account);
3447
3705
  const res = await provider.sendDraft(account, args.id);
3448
3706
  const data = { sent: true, id: res.id };
@@ -3478,6 +3736,8 @@ function registerComposeTools(server, ctx) {
3478
3736
  },
3479
3737
  async (args) => {
3480
3738
  try {
3739
+ const accessErr = checkAccountAccess(agentContext ?? null, args.account);
3740
+ if (accessErr) return fail(accessErr);
3481
3741
  const { provider, account } = registry.resolveByEmail(args.account);
3482
3742
  const res = await provider.addAttachmentToDraft(
3483
3743
  account,
@@ -3500,14 +3760,63 @@ function registerComposeTools(server, ctx) {
3500
3760
  }
3501
3761
  }
3502
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
+
3503
3805
  // src/tools/index.ts
3504
3806
  function registerTools(server, opts) {
3505
- const { store, registry, tools } = opts;
3506
- registerAccountTools(server, { store, registry, tools });
3507
- registerBrowseTools(server, { registry, tools });
3508
- registerFolderTools(server, { registry, tools });
3509
- registerOrganizeTools(server, { registry, tools });
3510
- 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
+ }
3511
3820
  }
3512
3821
 
3513
3822
  // src/version.ts
@@ -3515,33 +3824,38 @@ var VERSION = "0.4.1";
3515
3824
 
3516
3825
  // src/config.ts
3517
3826
  import { readFileSync } from "fs";
3518
- import { z as z7 } from "zod";
3519
- var httpConfigSchema = z7.object({
3520
- enabled: z7.boolean().default(false),
3521
- port: z7.number().int().min(1).max(65535).default(3e3),
3522
- 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")
3523
3832
  });
3524
- var toolsConfigSchema = z7.object({
3525
- disabled: z7.array(z7.string()).optional(),
3526
- 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()
3527
3836
  });
3528
- var outlookProviderSchema = z7.object({
3529
- clientId: z7.string().optional(),
3530
- tenantId: z7.string().optional()
3837
+ var outlookProviderSchema = z8.object({
3838
+ clientId: z8.string().optional(),
3839
+ tenantId: z8.string().optional()
3531
3840
  });
3532
- var gmailProviderSchema = z7.object({
3533
- clientId: z7.string().optional(),
3534
- clientSecret: z7.string().optional()
3841
+ var gmailProviderSchema = z8.object({
3842
+ clientId: z8.string().optional(),
3843
+ clientSecret: z8.string().optional()
3535
3844
  });
3536
- var providersConfigSchema = z7.object({
3845
+ var providersConfigSchema = z8.object({
3537
3846
  outlook: outlookProviderSchema.optional(),
3538
3847
  gmail: gmailProviderSchema.optional()
3539
3848
  });
3540
- var rawConfigSchema = z7.object({
3541
- 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(),
3542
3855
  http: httpConfigSchema.optional(),
3543
3856
  tools: toolsConfigSchema.optional(),
3544
- providers: providersConfigSchema.optional()
3857
+ providers: providersConfigSchema.optional(),
3858
+ watch: watchConfigSchema.optional()
3545
3859
  });
3546
3860
  var KNOWN_TOOLS = [
3547
3861
  "list_accounts",
@@ -3567,7 +3881,8 @@ var KNOWN_TOOLS = [
3567
3881
  "draft_email",
3568
3882
  "edit_draft",
3569
3883
  "send_draft",
3570
- "add_attachment_to_draft"
3884
+ "add_attachment_to_draft",
3885
+ "check_notifications"
3571
3886
  ];
3572
3887
  var ENV_VAR_RE = /\$\{([A-Z_][A-Z0-9_]*)\}/g;
3573
3888
  function resolveEnvVars(value) {
@@ -3633,7 +3948,9 @@ function loadConfig(configPath, cliOverrides = {}) {
3633
3948
  dataDir: cliOverrides.dataDir ?? parsed.dataDir ?? process.env.HYPERMAIL_MCP_DATA_DIR,
3634
3949
  http,
3635
3950
  tools: parsed.tools ? { disabled: parsed.tools.disabled, enabled: parsed.tools.enabled } : void 0,
3636
- providers: parsed.providers
3951
+ providers: parsed.providers,
3952
+ watch: parsed.watch,
3953
+ agentsConfigPath: cliOverrides.agentsConfig ?? process.env.HYPERMAIL_AGENTS_CONFIG
3637
3954
  };
3638
3955
  }
3639
3956
  function resolveTools(config) {
@@ -3646,25 +3963,284 @@ function resolveTools(config) {
3646
3963
  };
3647
3964
  }
3648
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
+
3649
4183
  // src/server.ts
3650
4184
  async function startServer(opts) {
3651
4185
  const { config } = opts;
3652
4186
  const store = await AccountStore.open({ dataDir: config.dataDir });
3653
4187
  const registry = buildRegistry({ store, providers: config.providers });
3654
4188
  const tools = resolveTools(config);
3655
- const server = new McpServer(
3656
- { name: "hypermail-mcp", version: VERSION },
3657
- { capabilities: { tools: {}, logging: {} } }
3658
- );
3659
- 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
+ };
3660
4199
  if (config.http.enabled) {
3661
- 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
+ }
3662
4237
  } else {
4238
+ const server = createServer();
3663
4239
  const transport = new StdioServerTransport();
3664
4240
  await server.connect(transport);
3665
4241
  }
3666
4242
  }
3667
- async function startHttp(server, host, port) {
4243
+ async function startHttp(createServer, host, port, notifyTargets, agentStore) {
3668
4244
  const sessions = /* @__PURE__ */ new Map();
3669
4245
  const http = createHttpServer(async (req, res) => {
3670
4246
  try {
@@ -3674,18 +4250,59 @@ async function startHttp(server, host, port) {
3674
4250
  return;
3675
4251
  }
3676
4252
  const sessionId = req.headers["mcp-session-id"] ?? void 0;
3677
- let transport = sessionId ? sessions.get(sessionId) : void 0;
3678
- if (!transport) {
3679
- 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({
3680
4279
  sessionIdGenerator: () => randomUUID6(),
3681
4280
  onsessioninitialized: (sid) => {
3682
- 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
+ };
3683
4302
  }
3684
4303
  });
3685
- transport.onclose = () => {
3686
- if (transport.sessionId) sessions.delete(transport.sessionId);
3687
- };
3688
4304
  await server.connect(transport);
4305
+ session = { transport, server, agentContext };
3689
4306
  }
3690
4307
  let body = void 0;
3691
4308
  if (req.method === "POST" || req.method === "DELETE") {
@@ -3694,7 +4311,7 @@ async function startHttp(server, host, port) {
3694
4311
  const raw = Buffer.concat(chunks).toString("utf8");
3695
4312
  body = raw ? JSON.parse(raw) : void 0;
3696
4313
  }
3697
- await transport.handleRequest(req, res, body);
4314
+ await session.transport.handleRequest(req, res, body);
3698
4315
  } catch (err) {
3699
4316
  console.error("[hypermail-mcp] http error:", err);
3700
4317
  if (!res.headersSent) {
@@ -3733,6 +4350,9 @@ function parseArgs(argv) {
3733
4350
  case "--config":
3734
4351
  out.config = String(argv[++i] ?? "");
3735
4352
  break;
4353
+ case "--agents-config":
4354
+ out.agentsConfig = String(argv[++i] ?? "");
4355
+ break;
3736
4356
  case "-h":
3737
4357
  case "--help":
3738
4358
  out.help = true;
@@ -3777,7 +4397,13 @@ Example hypermail-config.json:
3777
4397
  process.stdout.write(msg);
3778
4398
  }
3779
4399
  async function main() {
3780
- 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);
3781
4407
  if (opts.help) {
3782
4408
  printHelp();
3783
4409
  return;
@@ -3786,7 +4412,8 @@ async function main() {
3786
4412
  http: opts.http,
3787
4413
  port: opts.port,
3788
4414
  host: opts.host,
3789
- dataDir: opts.dataDir
4415
+ dataDir: opts.dataDir,
4416
+ agentsConfig: opts.agentsConfig
3790
4417
  });
3791
4418
  await startServer({ config });
3792
4419
  }