@adeu/mcp-server 1.6.8 → 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 +772 -143
- package/dist/index.js.map +1 -1
- package/package.json +2 -1
- package/src/desktop-auth.ts +127 -0
- package/src/index.ts +418 -204
- package/src/mcp.bugs.test.ts +162 -0
- 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,162 @@
|
|
|
1
|
+
// FILE: node/packages/mcp-server/src/mcp.bugs.test.ts
|
|
2
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
3
|
+
import { spawn, ChildProcess } from "node:child_process";
|
|
4
|
+
import { resolve, join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
|
|
7
|
+
import { DocumentObject, RedlineEngine } from "@adeu/core";
|
|
8
|
+
|
|
9
|
+
describe("Resolved Bugs MCP Server Verification", () => {
|
|
10
|
+
let serverProc: ChildProcess;
|
|
11
|
+
let cleanDocPath: string;
|
|
12
|
+
let dirtyDocPath: string;
|
|
13
|
+
|
|
14
|
+
beforeAll(async () => {
|
|
15
|
+
// 1. Grab the shared golden fixture from the monorepo root
|
|
16
|
+
const fixturePath = resolve(
|
|
17
|
+
__dirname,
|
|
18
|
+
"../../../../shared/fixtures/golden.docx",
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
cleanDocPath = join(tmpdir(), `adeu_clean_${Date.now()}.docx`);
|
|
22
|
+
dirtyDocPath = join(tmpdir(), `adeu_dirty_${Date.now()}.docx`);
|
|
23
|
+
|
|
24
|
+
// Save a clean copy
|
|
25
|
+
const fixtureBuf = readFileSync(fixturePath);
|
|
26
|
+
writeFileSync(cleanDocPath, fixtureBuf);
|
|
27
|
+
|
|
28
|
+
// Load it via the public API, dirty it, and save a dirty copy
|
|
29
|
+
const doc = await DocumentObject.load(fixtureBuf);
|
|
30
|
+
const engine = new RedlineEngine(doc, "Reviewer");
|
|
31
|
+
|
|
32
|
+
// "document" is original base text, so we won't trigger the cross-author nested redline constraint
|
|
33
|
+
engine.process_batch([
|
|
34
|
+
{
|
|
35
|
+
type: "modify",
|
|
36
|
+
target_text: "document",
|
|
37
|
+
new_text: "dirty modified document",
|
|
38
|
+
},
|
|
39
|
+
]);
|
|
40
|
+
writeFileSync(dirtyDocPath, await doc.save());
|
|
41
|
+
|
|
42
|
+
// 2. Boot the compiled MCP server
|
|
43
|
+
const serverPath = resolve(__dirname, "../dist/index.js");
|
|
44
|
+
if (!existsSync(serverPath)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
"MCP server not built. Run 'npm run build' before tests.",
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
serverProc = spawn("node", [serverPath]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterAll(() => {
|
|
54
|
+
if (serverProc && !serverProc.killed) serverProc.kill();
|
|
55
|
+
if (existsSync(cleanDocPath)) unlinkSync(cleanDocPath);
|
|
56
|
+
if (existsSync(dirtyDocPath)) unlinkSync(dirtyDocPath);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// Helper to interact with the stdio JSON-RPC server
|
|
60
|
+
function sendRpc(method: string, params: any, id: number = 1): Promise<any> {
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
const timeout = setTimeout(() => reject(new Error("RPC Timeout")), 5000);
|
|
63
|
+
|
|
64
|
+
const listener = (data: Buffer) => {
|
|
65
|
+
const lines = data.toString().trim().split("\n");
|
|
66
|
+
for (const line of lines) {
|
|
67
|
+
if (!line.startsWith("{")) continue;
|
|
68
|
+
try {
|
|
69
|
+
const res = JSON.parse(line);
|
|
70
|
+
if (res.id === id) {
|
|
71
|
+
clearTimeout(timeout);
|
|
72
|
+
serverProc.stdout?.removeListener("data", listener);
|
|
73
|
+
resolve(res);
|
|
74
|
+
}
|
|
75
|
+
} catch (e) {
|
|
76
|
+
// Ignore incomplete chunks
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
serverProc.stdout?.on("data", listener);
|
|
82
|
+
serverProc.stdin?.write(
|
|
83
|
+
JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n",
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
it("BUG-5: Rejects empty changes array early without writing files", async () => {
|
|
89
|
+
const outPath = join(tmpdir(), `adeu_out_${Date.now()}.docx`);
|
|
90
|
+
if (existsSync(outPath)) unlinkSync(outPath);
|
|
91
|
+
|
|
92
|
+
const res = await sendRpc(
|
|
93
|
+
"tools/call",
|
|
94
|
+
{
|
|
95
|
+
name: "process_document_batch",
|
|
96
|
+
arguments: {
|
|
97
|
+
original_docx_path: cleanDocPath,
|
|
98
|
+
author_name: "Agent",
|
|
99
|
+
changes: [],
|
|
100
|
+
output_path: outPath,
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
101,
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(res.result.content[0].text).toBe("Error: No changes provided.");
|
|
107
|
+
expect(existsSync(outPath)).toBe(false); // Proves no-op
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("BUG-9: diff_docx_files tool respects compare_clean parameter", async () => {
|
|
111
|
+
// 1. compare_clean = true (Default) -> Should output clean text comparison
|
|
112
|
+
const resClean = await sendRpc(
|
|
113
|
+
"tools/call",
|
|
114
|
+
{
|
|
115
|
+
name: "diff_docx_files",
|
|
116
|
+
arguments: {
|
|
117
|
+
original_path: cleanDocPath,
|
|
118
|
+
modified_path: dirtyDocPath,
|
|
119
|
+
compare_clean: true,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
102,
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const cleanText = resClean.result.content[0].text;
|
|
126
|
+
expect(cleanText).toContain("dirty modified");
|
|
127
|
+
expect(cleanText).not.toContain("{++"); // No CriticMarkup
|
|
128
|
+
|
|
129
|
+
// 2. compare_clean = false -> Should output raw CriticMarkup comparison
|
|
130
|
+
const resRaw = await sendRpc(
|
|
131
|
+
"tools/call",
|
|
132
|
+
{
|
|
133
|
+
name: "diff_docx_files",
|
|
134
|
+
arguments: {
|
|
135
|
+
original_path: cleanDocPath,
|
|
136
|
+
modified_path: dirtyDocPath,
|
|
137
|
+
compare_clean: false,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
103,
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
const rawText = resRaw.result.content[0].text;
|
|
144
|
+
expect(rawText).toContain("{++dirty modified ++}");
|
|
145
|
+
});
|
|
146
|
+
it("BUG-10: Traps ENOENT and returns clean File Not Found errors", async () => {
|
|
147
|
+
const res = await sendRpc(
|
|
148
|
+
"tools/call",
|
|
149
|
+
{
|
|
150
|
+
name: "read_docx",
|
|
151
|
+
arguments: { file_path: join(tmpdir(), "DEF_DOES_NOT_EXIST.docx") },
|
|
152
|
+
},
|
|
153
|
+
104,
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
expect(res.result.isError).toBe(true);
|
|
157
|
+
expect(res.result.content[0].text).toContain(
|
|
158
|
+
"Error executing tool read_docx: File not found:",
|
|
159
|
+
);
|
|
160
|
+
expect(res.result.content[0].text).not.toContain("ENOENT"); // Raw node error must not leak
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// FILE: node/packages/mcp-server/src/mcp.cloud.test.ts
|
|
2
|
+
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
3
|
+
import { spawn, ChildProcess } from "node:child_process";
|
|
4
|
+
import { resolve, join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import {
|
|
7
|
+
existsSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readFileSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
} from "node:fs";
|
|
13
|
+
|
|
14
|
+
describe("Cloud Auth & Email Tools MCP Verification", () => {
|
|
15
|
+
let serverProc: ChildProcess;
|
|
16
|
+
let testHomeDir: string;
|
|
17
|
+
let adeuConfigDir: string;
|
|
18
|
+
let credPath: string;
|
|
19
|
+
|
|
20
|
+
beforeAll(async () => {
|
|
21
|
+
// 1. Create a sandboxed home directory so we don't nuke the dev's actual login
|
|
22
|
+
testHomeDir = join(tmpdir(), `adeu_test_home_${Date.now()}`);
|
|
23
|
+
adeuConfigDir = join(testHomeDir, ".adeu");
|
|
24
|
+
credPath = join(adeuConfigDir, "credentials.json");
|
|
25
|
+
mkdirSync(testHomeDir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
// 2. Boot the compiled MCP server with the sandboxed HOME environment
|
|
28
|
+
const serverPath = resolve(__dirname, "../dist/index.js");
|
|
29
|
+
if (!existsSync(serverPath)) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
"MCP server not built. Run 'npm run build' before tests.",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
serverProc = spawn("node", [serverPath], {
|
|
36
|
+
env: {
|
|
37
|
+
...process.env,
|
|
38
|
+
HOME: testHomeDir, // Mock homedir() for macOS/Linux
|
|
39
|
+
USERPROFILE: testHomeDir, // Mock homedir() for Windows
|
|
40
|
+
},
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
afterAll(() => {
|
|
45
|
+
if (serverProc && !serverProc.killed) serverProc.kill();
|
|
46
|
+
if (existsSync(testHomeDir)) {
|
|
47
|
+
rmSync(testHomeDir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Helper to interact with the stdio JSON-RPC server
|
|
52
|
+
function sendRpc(method: string, params: any, id: number = 1): Promise<any> {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const timeout = setTimeout(() => reject(new Error("RPC Timeout")), 5000);
|
|
55
|
+
|
|
56
|
+
const listener = (data: Buffer) => {
|
|
57
|
+
const lines = data.toString().trim().split("\n");
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
if (!line.startsWith("{")) continue;
|
|
60
|
+
try {
|
|
61
|
+
const res = JSON.parse(line);
|
|
62
|
+
if (res.id === id) {
|
|
63
|
+
clearTimeout(timeout);
|
|
64
|
+
serverProc.stdout?.removeListener("data", listener);
|
|
65
|
+
resolve(res);
|
|
66
|
+
}
|
|
67
|
+
} catch (e) {
|
|
68
|
+
// Ignore incomplete chunks
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
serverProc.stdout?.on("data", listener);
|
|
74
|
+
serverProc.stdin?.write(
|
|
75
|
+
JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n",
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
it("CLOUD-1: Enforces authentication trap for search_and_fetch_emails", async () => {
|
|
81
|
+
// Attempt to search emails without a credentials.json file in the sandbox
|
|
82
|
+
const res = await sendRpc(
|
|
83
|
+
"tools/call",
|
|
84
|
+
{
|
|
85
|
+
name: "search_and_fetch_emails",
|
|
86
|
+
arguments: { subject: "Invoice" },
|
|
87
|
+
},
|
|
88
|
+
201,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
expect(res.result.isError).toBe(true);
|
|
92
|
+
expect(res.result.content[0].text).toContain("Authentication Required");
|
|
93
|
+
expect(res.result.content[0].text).toContain("login_to_adeu_cloud");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("CLOUD-2: Validates create_email_draft missing required arguments", async () => {
|
|
97
|
+
// To bypass the auth trap for this test, we inject a dummy credential file
|
|
98
|
+
mkdirSync(adeuConfigDir, { recursive: true });
|
|
99
|
+
writeFileSync(credPath, JSON.stringify({ api_key: "dummy_test_key" }));
|
|
100
|
+
|
|
101
|
+
const res = await sendRpc(
|
|
102
|
+
"tools/call",
|
|
103
|
+
{
|
|
104
|
+
name: "create_email_draft",
|
|
105
|
+
arguments: {
|
|
106
|
+
body_markdown: "Hello World",
|
|
107
|
+
// Missing reply_to_email_id AND subject/to_recipients
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
202,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
expect(res.result.isError).toBe(true);
|
|
114
|
+
expect(res.result.content[0].text).toContain(
|
|
115
|
+
"You must provide either 'reply_to_email_id' OR both 'subject' and 'to_recipients'",
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("CLOUD-3: logout_of_adeu_cloud successfully deletes the credentials file", async () => {
|
|
120
|
+
// Ensure the dummy file from the previous test exists
|
|
121
|
+
expect(existsSync(credPath)).toBe(true);
|
|
122
|
+
|
|
123
|
+
const res = await sendRpc(
|
|
124
|
+
"tools/call",
|
|
125
|
+
{
|
|
126
|
+
name: "logout_of_adeu_cloud",
|
|
127
|
+
arguments: {},
|
|
128
|
+
},
|
|
129
|
+
203,
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(res.result.isError).toBeFalsy();
|
|
133
|
+
expect(res.result.content[0].text).toContain("Successfully logged out");
|
|
134
|
+
|
|
135
|
+
// Verify the file was physically deleted from the sandboxed .adeu directory
|
|
136
|
+
expect(existsSync(credPath)).toBe(false);
|
|
137
|
+
});
|
|
138
|
+
});
|
package/src/shared.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// FILE: node/packages/mcp-server/src/shared.ts
|
|
2
|
+
export const FRONTEND_URL =
|
|
3
|
+
process.env.ADEU_FRONTEND_URL || "https://app.adeu.ai";
|
|
4
|
+
export const BACKEND_URL =
|
|
5
|
+
process.env.ADEU_BACKEND_URL || "https://app.adeu.ai";
|
|
6
|
+
export const MARKDOWN_UI_URI = "ui://adeu/markdown-ui";
|
|
7
|
+
export const EMAIL_UI_URI = "ui://adeu/email-ui";
|
|
@@ -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
|
+
}
|