@adeu/mcp-server 1.6.9 → 1.7.1
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 +42 -0
- package/dist/index.js +495 -4
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/desktop-auth.ts +127 -0
- package/src/index.ts +67 -5
- package/src/mcp.cloud.test.ts +138 -0
- package/src/shared.ts +7 -0
- package/src/tools/auth.ts +49 -0
- package/src/tools/email.ts +313 -0
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// FILE: node/packages/mcp-server/src/tools/auth.ts
|
|
2
|
+
import { DesktopAuthManager } from "../desktop-auth.js";
|
|
3
|
+
import { BACKEND_URL } from "../shared.js";
|
|
4
|
+
import { ToolResult } from "../response-builders.js";
|
|
5
|
+
|
|
6
|
+
export async function login_to_adeu_cloud(): Promise<ToolResult> {
|
|
7
|
+
try {
|
|
8
|
+
const apiKey = await DesktopAuthManager.ensureAuthenticated();
|
|
9
|
+
|
|
10
|
+
const res = await fetch(`${BACKEND_URL}/api/v1/auth/me`, {
|
|
11
|
+
headers: {
|
|
12
|
+
Authorization: `Bearer ${apiKey}`,
|
|
13
|
+
Accept: "application/json",
|
|
14
|
+
},
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
if (res.status === 401) {
|
|
18
|
+
DesktopAuthManager.clearApiKey();
|
|
19
|
+
throw new Error(
|
|
20
|
+
"Your previous session expired. The stale key has been cleared. Please call `login_to_adeu_cloud` ONE MORE TIME to log in fresh.",
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
if (!res.ok) throw new Error(`HTTP Error: ${res.status}`);
|
|
24
|
+
|
|
25
|
+
const data: any = await res.json();
|
|
26
|
+
return {
|
|
27
|
+
content: [
|
|
28
|
+
{
|
|
29
|
+
type: "text",
|
|
30
|
+
text: `Login successful! Connected to Adeu Cloud as: ${data.email || "Unknown Email"}.`,
|
|
31
|
+
},
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
} catch (err: any) {
|
|
35
|
+
return { isError: true, content: [{ type: "text", text: err.message }] };
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function logout_of_adeu_cloud(): Promise<ToolResult> {
|
|
40
|
+
DesktopAuthManager.clearApiKey();
|
|
41
|
+
return {
|
|
42
|
+
content: [
|
|
43
|
+
{
|
|
44
|
+
type: "text",
|
|
45
|
+
text: "Successfully logged out. The local API key has been removed.",
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
// FILE: node/packages/mcp-server/src/tools/email.ts
|
|
2
|
+
import { homedir, tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
5
|
+
import { DesktopAuthManager, getCloudAuthToken } from "../desktop-auth.js";
|
|
6
|
+
import { BACKEND_URL } from "../shared.js";
|
|
7
|
+
import { ToolResult } from "../response-builders.js";
|
|
8
|
+
import { createHash } from "node:crypto";
|
|
9
|
+
|
|
10
|
+
const CACHE_FILE = join(homedir(), ".adeu", "mcp_id_cache.json");
|
|
11
|
+
const MAX_CACHE_SIZE = 1000;
|
|
12
|
+
|
|
13
|
+
function loadIdCache(): Record<string, string> {
|
|
14
|
+
if (existsSync(CACHE_FILE)) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
|
|
17
|
+
} catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return {};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function saveIdCache(cache: Record<string, string>): void {
|
|
25
|
+
try {
|
|
26
|
+
mkdirSync(join(homedir(), ".adeu"), { recursive: true });
|
|
27
|
+
const keys = Object.keys(cache);
|
|
28
|
+
if (keys.length > MAX_CACHE_SIZE) {
|
|
29
|
+
const trimmed: Record<string, string> = {};
|
|
30
|
+
keys.slice(-MAX_CACHE_SIZE).forEach((k) => (trimmed[k] = cache[k]));
|
|
31
|
+
cache = trimmed;
|
|
32
|
+
}
|
|
33
|
+
writeFileSync(CACHE_FILE, JSON.stringify(cache));
|
|
34
|
+
} catch {
|
|
35
|
+
/* ignore */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function minifyEmailId(realId: string, cache: Record<string, string>): string {
|
|
40
|
+
if (!realId) return realId;
|
|
41
|
+
const hash = createHash("md5").update(realId).digest("hex").slice(0, 6);
|
|
42
|
+
const shortId = `msg_${hash}`;
|
|
43
|
+
cache[shortId] = realId;
|
|
44
|
+
return shortId;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveEmailId(shortId: string): string {
|
|
48
|
+
if (!shortId) return shortId;
|
|
49
|
+
const cache = loadIdCache();
|
|
50
|
+
return cache[shortId] || shortId;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function stripTags(html: string): string {
|
|
54
|
+
if (!html) return "";
|
|
55
|
+
let text = html.replace(/<(style|script|head)[^>]*>[\s\S]*?<\/\1>/gi, "");
|
|
56
|
+
text = text.replace(
|
|
57
|
+
/<\/?(p|div|br|hr|tr|li|h[1-6]|blockquote)\b[^>]*>/gi,
|
|
58
|
+
"\n",
|
|
59
|
+
);
|
|
60
|
+
text = text.replace(/<[^>]+>/g, "");
|
|
61
|
+
return text.replace(/\n\s*\n\s*\n+/g, "\n\n").trim();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function removeNestedQuotes(text: string): string {
|
|
65
|
+
if (!text) return "";
|
|
66
|
+
const patterns = [
|
|
67
|
+
/_{10,}/m,
|
|
68
|
+
/^From:\s.*?\n(?:.*\n){0,5}?Sent:\s/m,
|
|
69
|
+
/-----Original Message-----/m,
|
|
70
|
+
/On .{1,200}? wrote:/m,
|
|
71
|
+
/^Original Message$/m,
|
|
72
|
+
];
|
|
73
|
+
let earliestCut = text.length;
|
|
74
|
+
for (const pattern of patterns) {
|
|
75
|
+
const match = pattern.exec(text);
|
|
76
|
+
if (match && match.index < earliestCut) {
|
|
77
|
+
earliestCut = match.index;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return text.substring(0, earliestCut).trim();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function getUniqueFilepath(saveDir: string, filename: string): string {
|
|
84
|
+
let filepath = join(saveDir, filename);
|
|
85
|
+
let counter = 1;
|
|
86
|
+
const parts = filename.split(".");
|
|
87
|
+
const ext = parts.length > 1 ? `.${parts.pop()}` : "";
|
|
88
|
+
const stem = parts.join(".");
|
|
89
|
+
|
|
90
|
+
while (existsSync(filepath)) {
|
|
91
|
+
filepath = join(saveDir, `${stem}_${counter}${ext}`);
|
|
92
|
+
counter++;
|
|
93
|
+
}
|
|
94
|
+
return filepath;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function search_and_fetch_emails(args: any): Promise<ToolResult> {
|
|
98
|
+
const apiKey = await getCloudAuthToken();
|
|
99
|
+
const realEmailId = args.email_id ? resolveEmailId(args.email_id) : undefined;
|
|
100
|
+
|
|
101
|
+
const payload = {
|
|
102
|
+
email_id: realEmailId,
|
|
103
|
+
sender: args.sender,
|
|
104
|
+
subject: args.subject,
|
|
105
|
+
has_attachments: args.has_attachments,
|
|
106
|
+
attachment_name: args.attachment_name,
|
|
107
|
+
is_unread: args.is_unread,
|
|
108
|
+
days_ago: args.days_ago,
|
|
109
|
+
folder: args.folder,
|
|
110
|
+
limit: args.limit ?? 10,
|
|
111
|
+
offset: args.offset ?? 0,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// Remove undefined fields
|
|
115
|
+
Object.keys(payload).forEach(
|
|
116
|
+
(k) => (payload as any)[k] === undefined && delete (payload as any)[k],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const res = await fetch(`${BACKEND_URL}/api/v1/emails/search`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: {
|
|
122
|
+
Authorization: `Bearer ${apiKey}`,
|
|
123
|
+
"Content-Type": "application/json",
|
|
124
|
+
},
|
|
125
|
+
body: JSON.stringify(payload),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
if (res.status === 401) {
|
|
129
|
+
DesktopAuthManager.clearApiKey();
|
|
130
|
+
throw new Error(
|
|
131
|
+
"Authentication expired. Please call `login_to_adeu_cloud` to re-authenticate.",
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
if (!res.ok) throw new Error(`Cloud search failed: ${await res.text()}`);
|
|
135
|
+
|
|
136
|
+
const data: any = await res.json();
|
|
137
|
+
const cache = loadIdCache();
|
|
138
|
+
|
|
139
|
+
if (data.type === "previews") {
|
|
140
|
+
const previews = data.previews || [];
|
|
141
|
+
if (!previews.length)
|
|
142
|
+
return {
|
|
143
|
+
content: [
|
|
144
|
+
{
|
|
145
|
+
type: "text",
|
|
146
|
+
text: "No emails found matching your search criteria.",
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const lines = [
|
|
152
|
+
`Found ${previews.length} email(s). Here are the previews:`,
|
|
153
|
+
"",
|
|
154
|
+
];
|
|
155
|
+
for (const p of previews) {
|
|
156
|
+
const shortId = minifyEmailId(p.id, cache);
|
|
157
|
+
const attFlag = p.has_attachments ? "📎 (Has Attachments)" : "";
|
|
158
|
+
const unreadFlag = p.is_read === false ? "🟢 [UNREAD]" : "";
|
|
159
|
+
lines.push(
|
|
160
|
+
`- **ID**: \`${shortId}\`\n **Subject**: ${p.subject} ${attFlag} ${unreadFlag}\n **From**: ${p.sender_name} <${p.sender_email}>\n **Date**: ${p.received_datetime}\n **Preview**: ${p.preview_text}\n`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
saveIdCache(cache);
|
|
164
|
+
lines.push(
|
|
165
|
+
"⚠️ **ACTION REQUIRED**: To read the full body of an email and download its attachments, call this tool again and provide the exact `email_id`.",
|
|
166
|
+
);
|
|
167
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (data.type === "full_email") {
|
|
171
|
+
const full = data.full_email || {};
|
|
172
|
+
const shortTargetId = minifyEmailId(full.id || "unknown_id", cache);
|
|
173
|
+
|
|
174
|
+
saveIdCache(cache);
|
|
175
|
+
|
|
176
|
+
const baseDir =
|
|
177
|
+
args.working_directory && existsSync(args.working_directory)
|
|
178
|
+
? args.working_directory
|
|
179
|
+
: tmpdir();
|
|
180
|
+
const saveDir = join(
|
|
181
|
+
baseDir,
|
|
182
|
+
args.working_directory ? "adeu_attachments" : "adeu_downloads",
|
|
183
|
+
shortTargetId,
|
|
184
|
+
);
|
|
185
|
+
mkdirSync(saveDir, { recursive: true });
|
|
186
|
+
|
|
187
|
+
async function processAttachments(msg: any): Promise<string[]> {
|
|
188
|
+
const localFiles: string[] = [];
|
|
189
|
+
for (const att of msg.attachments || []) {
|
|
190
|
+
if (att.base64_data) {
|
|
191
|
+
try {
|
|
192
|
+
const filepath = getUniqueFilepath(
|
|
193
|
+
saveDir,
|
|
194
|
+
att.filename || "unnamed_file",
|
|
195
|
+
);
|
|
196
|
+
writeFileSync(filepath, Buffer.from(att.base64_data, "base64"));
|
|
197
|
+
localFiles.push(filepath);
|
|
198
|
+
delete att.base64_data; // Free memory
|
|
199
|
+
} catch (e) {
|
|
200
|
+
console.error(`Failed to save attachment ${att.filename}`, e);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return localFiles;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const targetFiles = await processAttachments(full);
|
|
208
|
+
const lines = [
|
|
209
|
+
`# Email Thread: ${full.subject}`,
|
|
210
|
+
"",
|
|
211
|
+
"## Target Message (Newest):",
|
|
212
|
+
`**From**: ${full.sender_name} <${full.sender_email}>`,
|
|
213
|
+
`**Date**: ${full.received_datetime}`,
|
|
214
|
+
];
|
|
215
|
+
|
|
216
|
+
if (targetFiles.length) {
|
|
217
|
+
lines.push("**Attachments Saved Locally**:");
|
|
218
|
+
targetFiles.forEach((f) => lines.push(`- 📎 \`${f}\``));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const cleanBody = removeNestedQuotes(stripTags(full.body_html || ""));
|
|
222
|
+
lines.push(`**Body**:\n\`\`\`\n${cleanBody}\n\`\`\`\n`);
|
|
223
|
+
|
|
224
|
+
if (full.is_thread && full.messages?.length) {
|
|
225
|
+
lines.push("## Previous Messages in Thread (Historical Context):");
|
|
226
|
+
for (let i = 0; i < full.messages.length; i++) {
|
|
227
|
+
const histMsg = full.messages[i];
|
|
228
|
+
const histFiles = await processAttachments(histMsg);
|
|
229
|
+
lines.push(
|
|
230
|
+
`### Message -${i + 1} (Older)\n**From**: ${histMsg.sender_name} <${histMsg.sender_email}>\n**Date**: ${histMsg.received_datetime}`,
|
|
231
|
+
);
|
|
232
|
+
if (histFiles.length) {
|
|
233
|
+
lines.push("**Attachments Saved Locally**:");
|
|
234
|
+
histFiles.forEach((f) => lines.push(`- 📎 \`${f}\``));
|
|
235
|
+
}
|
|
236
|
+
lines.push(
|
|
237
|
+
`**Body**:\n\`\`\`\n${removeNestedQuotes(stripTags(histMsg.body_html || ""))}\n\`\`\`\n`,
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return { content: [{ type: "text", text: lines.join("\n") }] };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
isError: true,
|
|
246
|
+
content: [{ type: "text", text: "Unknown response format from backend." }],
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function create_email_draft(args: any): Promise<ToolResult> {
|
|
251
|
+
const apiKey = await getCloudAuthToken();
|
|
252
|
+
if (!args.reply_to_email_id && (!args.subject || !args.to_recipients)) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
"You must provide either 'reply_to_email_id' OR both 'subject' and 'to_recipients'.",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const formData = new FormData();
|
|
259
|
+
formData.append("body_markdown", args.body_markdown);
|
|
260
|
+
|
|
261
|
+
if (args.reply_to_email_id) {
|
|
262
|
+
formData.append(
|
|
263
|
+
"reply_to_email_id",
|
|
264
|
+
resolveEmailId(args.reply_to_email_id),
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
if (args.subject) formData.append("subject", args.subject);
|
|
268
|
+
|
|
269
|
+
if (args.to_recipients) {
|
|
270
|
+
const recips =
|
|
271
|
+
typeof args.to_recipients === "string"
|
|
272
|
+
? JSON.parse(args.to_recipients)
|
|
273
|
+
: args.to_recipients;
|
|
274
|
+
formData.append("to_recipients", JSON.stringify(recips));
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (args.attachment_paths) {
|
|
278
|
+
const paths =
|
|
279
|
+
typeof args.attachment_paths === "string"
|
|
280
|
+
? JSON.parse(args.attachment_paths)
|
|
281
|
+
: args.attachment_paths;
|
|
282
|
+
for (const p of paths) {
|
|
283
|
+
const buf = readFileSync(p);
|
|
284
|
+
const filename = p.split(/[/\\]/).pop();
|
|
285
|
+
formData.append("files", new Blob([buf]), filename);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const res = await fetch(`${BACKEND_URL}/api/v1/emails/drafts/new`, {
|
|
290
|
+
method: "POST",
|
|
291
|
+
headers: { Authorization: `Bearer ${apiKey}`, Accept: "application/json" },
|
|
292
|
+
body: formData as any,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
if (res.status === 401) {
|
|
296
|
+
DesktopAuthManager.clearApiKey();
|
|
297
|
+
throw new Error(
|
|
298
|
+
"Authentication expired. Please call `login_to_adeu_cloud`.",
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
if (!res.ok)
|
|
302
|
+
throw new Error(`Cloud draft creation failed: ${await res.text()}`);
|
|
303
|
+
|
|
304
|
+
const data: any = await res.json();
|
|
305
|
+
return {
|
|
306
|
+
content: [
|
|
307
|
+
{
|
|
308
|
+
type: "text",
|
|
309
|
+
text: `Successfully created email draft! Draft ID: ${data.id}`,
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
};
|
|
313
|
+
}
|