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/README.md +190 -12
- package/dist/cli.js +801 -171
- package/dist/cli.js.map +1 -1
- package/package.json +6 -2
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 (
|
|
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
|
|
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
|
-
|
|
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
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
503
|
+
const existing2 = refreshLocks.get(key);
|
|
504
|
+
if (existing2) {
|
|
505
|
+
try {
|
|
506
|
+
return await existing2;
|
|
507
|
+
} catch {
|
|
508
|
+
}
|
|
353
509
|
}
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
tokens
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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
|
|
1399
|
-
const result = await imap.mailboxCreate(
|
|
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
|
|
1737
|
-
const
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
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,
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3516
|
-
var httpConfigSchema =
|
|
3517
|
-
enabled:
|
|
3518
|
-
port:
|
|
3519
|
-
host:
|
|
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 =
|
|
3522
|
-
disabled:
|
|
3523
|
-
enabled:
|
|
3833
|
+
var toolsConfigSchema = z8.object({
|
|
3834
|
+
disabled: z8.array(z8.string()).optional(),
|
|
3835
|
+
enabled: z8.array(z8.string()).optional()
|
|
3524
3836
|
});
|
|
3525
|
-
var outlookProviderSchema =
|
|
3526
|
-
clientId:
|
|
3527
|
-
tenantId:
|
|
3837
|
+
var outlookProviderSchema = z8.object({
|
|
3838
|
+
clientId: z8.string().optional(),
|
|
3839
|
+
tenantId: z8.string().optional()
|
|
3528
3840
|
});
|
|
3529
|
-
var gmailProviderSchema =
|
|
3530
|
-
clientId:
|
|
3531
|
-
clientSecret:
|
|
3841
|
+
var gmailProviderSchema = z8.object({
|
|
3842
|
+
clientId: z8.string().optional(),
|
|
3843
|
+
clientSecret: z8.string().optional()
|
|
3532
3844
|
});
|
|
3533
|
-
var providersConfigSchema =
|
|
3845
|
+
var providersConfigSchema = z8.object({
|
|
3534
3846
|
outlook: outlookProviderSchema.optional(),
|
|
3535
3847
|
gmail: gmailProviderSchema.optional()
|
|
3536
3848
|
});
|
|
3537
|
-
var
|
|
3538
|
-
|
|
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
|
|
3653
|
-
|
|
3654
|
-
|
|
3655
|
-
|
|
3656
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
3675
|
-
if (!
|
|
3676
|
-
|
|
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
|
|
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
|
}
|