@adeu/mcp-server 1.10.1 → 1.12.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 +15 -1
- package/dist/index.js +160 -61
- package/dist/index.js.map +1 -1
- package/package.json +4 -2
- package/scripts/verify-bundle.js +50 -0
- package/src/index.test.ts +21 -2
- package/src/index.ts +59 -10
- package/src/mcp.bugs.test.ts +2 -0
- package/src/parity_live.test.ts +265 -0
- package/src/response-builders.ts +2 -0
- package/src/tools/email.test.ts +104 -1
- package/src/tools/email.ts +137 -51
- package/tests/fixtures/gap1_deleted_row_repro.docx +0 -0
- package/tests/fixtures/gap1_minimal_repro.docx +0 -0
- package/tests/fixtures/gap2_minimal_repro.docx +0 -0
- package/tests/fixtures/generate_fixtures.py +69 -0
- package/tsup.config.ts +24 -0
package/src/index.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { readFileSync, existsSync } from "node:fs";
|
|
|
5
5
|
import { basename, resolve, extname, dirname, join } from "node:path";
|
|
6
6
|
import { z } from "zod";
|
|
7
7
|
import {
|
|
8
|
-
registerAppTool,
|
|
8
|
+
registerAppTool as origRegisterAppTool,
|
|
9
9
|
registerAppResource,
|
|
10
10
|
RESOURCE_MIME_TYPE,
|
|
11
11
|
} from "@modelcontextprotocol/ext-apps/server";
|
|
@@ -13,6 +13,7 @@ import fs from "node:fs";
|
|
|
13
13
|
import {
|
|
14
14
|
identifyEngine,
|
|
15
15
|
extractTextFromBuffer,
|
|
16
|
+
_extractTextFromDoc,
|
|
16
17
|
DocumentObject,
|
|
17
18
|
RedlineEngine,
|
|
18
19
|
BatchValidationError,
|
|
@@ -39,7 +40,12 @@ function readFileBytesOrThrow(filePath: string): Buffer {
|
|
|
39
40
|
return readFileSync(filePath);
|
|
40
41
|
} catch (err: any) {
|
|
41
42
|
if (err.code === "ENOENT") {
|
|
42
|
-
throw new Error(
|
|
43
|
+
throw new Error(
|
|
44
|
+
`File not found: ${filePath}. Note: If you are running in a sandboxed/containerized environment, ` +
|
|
45
|
+
`the host application or MCP server may not have access to your local workspace files. ` +
|
|
46
|
+
`You can resolve this by installing Adeu directly inside your sandboxed environment using ` +
|
|
47
|
+
`'uv tool install adeu' and executing the commands via the CLI.`
|
|
48
|
+
);
|
|
43
49
|
}
|
|
44
50
|
throw err;
|
|
45
51
|
}
|
|
@@ -74,12 +80,38 @@ const PROCESS_BATCH_OPERATIONS_DESC =
|
|
|
74
80
|
const DIFF_DOCX_DESC =
|
|
75
81
|
"Compares two DOCX files and returns a unified diff of their text content. Useful for analyzing differences between versions before editing.";
|
|
76
82
|
|
|
83
|
+
const gitSha = process.env.GIT_SHA || "unknown";
|
|
84
|
+
const buildTs = process.env.BUILD_TIMESTAMP || "unknown";
|
|
85
|
+
const packageVersion = process.env.PACKAGE_VERSION || "unknown";
|
|
86
|
+
const buildTag = ` [Adeu v${packageVersion}+${gitSha}]`;
|
|
87
|
+
|
|
77
88
|
// --- Server Setup ---
|
|
78
89
|
const server = new McpServer({
|
|
79
90
|
name: "adeu-redlining-service",
|
|
80
|
-
version:
|
|
91
|
+
version: packageVersion,
|
|
81
92
|
});
|
|
82
93
|
|
|
94
|
+
// Wrap server.registerTool to inject buildTag into descriptions
|
|
95
|
+
const originalRegisterTool = server.registerTool.bind(server);
|
|
96
|
+
server.registerTool = (name: string, schema: any, handler?: any) => {
|
|
97
|
+
if (schema && typeof schema === "object") {
|
|
98
|
+
if (schema.description) {
|
|
99
|
+
schema.description = schema.description.trim() + buildTag;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return originalRegisterTool(name, schema, handler);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Wrap registerAppTool to inject buildTag into descriptions
|
|
106
|
+
const registerAppTool: typeof origRegisterAppTool = (mcpServer, name, schema, handler) => {
|
|
107
|
+
if (schema && typeof schema === "object") {
|
|
108
|
+
if (schema.description) {
|
|
109
|
+
schema.description = schema.description.trim() + buildTag;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return origRegisterAppTool(mcpServer, name, schema, handler);
|
|
113
|
+
};
|
|
114
|
+
|
|
83
115
|
// Common CSP allowing Google Fonts used by Adeu UI templates
|
|
84
116
|
const UI_CSP = {
|
|
85
117
|
connectDomains: ["https://fonts.googleapis.com", "https://fonts.gstatic.com"],
|
|
@@ -205,22 +237,31 @@ registerAppTool(
|
|
|
205
237
|
}) => {
|
|
206
238
|
try {
|
|
207
239
|
const buf = readFileBytesOrThrow(file_path);
|
|
208
|
-
const text = await extractTextFromBuffer(buf, clean_view);
|
|
209
240
|
|
|
210
241
|
if (mode === "outline") {
|
|
211
242
|
const doc = await DocumentObject.load(buf);
|
|
212
|
-
|
|
243
|
+
const extract_res = _extractTextFromDoc(doc, clean_view, true, true) as {
|
|
244
|
+
text: string;
|
|
245
|
+
paragraph_offsets: Map<any, [number, number]>;
|
|
246
|
+
};
|
|
247
|
+
const res = build_outline_response(
|
|
213
248
|
doc,
|
|
214
|
-
text,
|
|
249
|
+
extract_res.text,
|
|
215
250
|
file_path,
|
|
216
251
|
outline_max_level,
|
|
217
252
|
outline_verbose,
|
|
218
|
-
|
|
253
|
+
extract_res.paragraph_offsets,
|
|
254
|
+
);
|
|
255
|
+
return res as any;
|
|
219
256
|
}
|
|
257
|
+
|
|
258
|
+
const text = await extractTextFromBuffer(buf, clean_view);
|
|
220
259
|
if (mode === "appendix") {
|
|
221
|
-
|
|
260
|
+
const res = build_appendix_response(text, page, file_path);
|
|
261
|
+
return res as any;
|
|
222
262
|
}
|
|
223
|
-
|
|
263
|
+
const res = build_paginated_response(text, page, file_path);
|
|
264
|
+
return res as any;
|
|
224
265
|
} catch (e: any) {
|
|
225
266
|
return {
|
|
226
267
|
isError: true,
|
|
@@ -235,6 +276,8 @@ registerAppTool(
|
|
|
235
276
|
},
|
|
236
277
|
);
|
|
237
278
|
|
|
279
|
+
|
|
280
|
+
|
|
238
281
|
registerAppTool(
|
|
239
282
|
server,
|
|
240
283
|
"search_and_fetch_emails",
|
|
@@ -268,6 +311,10 @@ registerAppTool(
|
|
|
268
311
|
.string()
|
|
269
312
|
.optional()
|
|
270
313
|
.describe("Optional target mailbox email address to search within."),
|
|
314
|
+
task_id: z
|
|
315
|
+
.string()
|
|
316
|
+
.optional()
|
|
317
|
+
.describe("If resuming a pending check, provide the task ID here."),
|
|
271
318
|
max_attachment_size_mb: z
|
|
272
319
|
.number()
|
|
273
320
|
.optional()
|
|
@@ -683,8 +730,10 @@ export function formatBatchResult(
|
|
|
683
730
|
async function main() {
|
|
684
731
|
const transport = new StdioServerTransport();
|
|
685
732
|
await server.connect(transport);
|
|
733
|
+
const gitSha = process.env.GIT_SHA || "unknown";
|
|
734
|
+
const buildTs = process.env.BUILD_TIMESTAMP || "unknown";
|
|
686
735
|
console.error(
|
|
687
|
-
`Adeu MCP Server (Node.js Engine: ${identifyEngine()}) running on stdio`,
|
|
736
|
+
`Adeu MCP Server (Node.js Engine: ${identifyEngine()}) running on stdio build=${gitSha}@${buildTs}`,
|
|
688
737
|
);
|
|
689
738
|
}
|
|
690
739
|
|
package/src/mcp.bugs.test.ts
CHANGED
|
@@ -157,6 +157,8 @@ describe("Resolved Bugs MCP Server Verification", () => {
|
|
|
157
157
|
expect(res.result.content[0].text).toContain(
|
|
158
158
|
"Error executing tool read_docx: File not found:",
|
|
159
159
|
);
|
|
160
|
+
expect(res.result.content[0].text).toContain("sandboxed/containerized environment");
|
|
161
|
+
expect(res.result.content[0].text).toContain("uv tool install adeu");
|
|
160
162
|
expect(res.result.content[0].text).not.toContain("ENOENT"); // Raw node error must not leak
|
|
161
163
|
});
|
|
162
164
|
});
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// FILE: node/packages/mcp-server/src/parity_live.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 { existsSync, readFileSync } from "node:fs";
|
|
6
|
+
|
|
7
|
+
describe("Parity Live Server Integration Verification", () => {
|
|
8
|
+
let serverProc: ChildProcess;
|
|
9
|
+
const fixturePath = resolve(__dirname, "../tests/fixtures/gap2_minimal_repro.docx");
|
|
10
|
+
|
|
11
|
+
beforeAll(async () => {
|
|
12
|
+
const serverPath = resolve(__dirname, "../dist/index.js");
|
|
13
|
+
if (!existsSync(serverPath)) {
|
|
14
|
+
throw new Error(
|
|
15
|
+
"MCP server not built. Run 'npm run build' before tests.",
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
if (!existsSync(fixturePath)) {
|
|
19
|
+
throw new Error(`Fixture not found: ${fixturePath}`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Spawn server process
|
|
23
|
+
serverProc = spawn("node", [serverPath]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterAll(() => {
|
|
27
|
+
if (serverProc && !serverProc.killed) {
|
|
28
|
+
serverProc.kill();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Helper to interact with the stdio JSON-RPC server
|
|
33
|
+
function sendRpc(method: string, params: any, id: number = 1): Promise<any> {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const timeout = setTimeout(() => reject(new Error("RPC Timeout")), 5000);
|
|
36
|
+
|
|
37
|
+
const listener = (data: Buffer) => {
|
|
38
|
+
const lines = data.toString().trim().split("\n");
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
if (!line.startsWith("{")) continue;
|
|
41
|
+
try {
|
|
42
|
+
const res = JSON.parse(line);
|
|
43
|
+
if (res.id === id) {
|
|
44
|
+
clearTimeout(timeout);
|
|
45
|
+
serverProc.stdout?.removeListener("data", listener);
|
|
46
|
+
resolve(res);
|
|
47
|
+
}
|
|
48
|
+
} catch (e) {
|
|
49
|
+
// Ignore incomplete chunks
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
serverProc.stdout?.on("data", listener);
|
|
55
|
+
serverProc.stdin?.write(
|
|
56
|
+
JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n",
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
it("does not expose the server_info tool anymore", async () => {
|
|
62
|
+
const listRes = await sendRpc("tools/list", {}, 200);
|
|
63
|
+
expect(listRes.result).toBeDefined();
|
|
64
|
+
const tools = listRes.result.tools || [];
|
|
65
|
+
const serverInfoTool = tools.find((t: any) => t.name === "server_info");
|
|
66
|
+
expect(serverInfoTool).toBeUndefined();
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("exposes the build stamp via tool-call descriptions", async () => {
|
|
70
|
+
const listRes = await sendRpc("tools/list", {}, 201);
|
|
71
|
+
expect(listRes.result).toBeDefined();
|
|
72
|
+
const tools = listRes.result.tools || [];
|
|
73
|
+
const readDocx = tools.find((t: any) => t.name === "read_docx");
|
|
74
|
+
expect(readDocx).toBeDefined();
|
|
75
|
+
expect(readDocx.description).toContain("[Adeu v");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("exposes the build stamp via serverInfo.version in initialize", async () => {
|
|
79
|
+
const initRes = await sendRpc("initialize", {
|
|
80
|
+
protocolVersion: "2024-11-05",
|
|
81
|
+
capabilities: {},
|
|
82
|
+
clientInfo: { name: "test-client", version: "1.0.0" }
|
|
83
|
+
}, 202);
|
|
84
|
+
expect(initRes.result).toBeDefined();
|
|
85
|
+
expect(initRes.result.serverInfo).toBeDefined();
|
|
86
|
+
expect(initRes.result.serverInfo.version).not.toBe("1.0.0");
|
|
87
|
+
expect(initRes.result.serverInfo.version).not.toContain("unknown");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
it("read_docx appends [Debug] build stamp footer to mode=outline and mode=full", async () => {
|
|
92
|
+
const res = await sendRpc("tools/call", {
|
|
93
|
+
name: "read_docx",
|
|
94
|
+
arguments: {
|
|
95
|
+
file_path: fixturePath,
|
|
96
|
+
mode: "outline",
|
|
97
|
+
},
|
|
98
|
+
}, 202);
|
|
99
|
+
|
|
100
|
+
expect(res.result).toBeDefined();
|
|
101
|
+
expect(res.result.content[0].text).not.toContain("[Debug] build=");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("GAP 2 (Live): process_document_batch modify straddling deleted text returns actionable deletion error", async () => {
|
|
105
|
+
const outPath = join(resolve(__dirname, "../../../../tmp"), `live_gap2_out_${Date.now()}.docx`);
|
|
106
|
+
const res = await sendRpc("tools/call", {
|
|
107
|
+
name: "process_document_batch",
|
|
108
|
+
arguments: {
|
|
109
|
+
original_docx_path: fixturePath,
|
|
110
|
+
author_name: "Ed",
|
|
111
|
+
changes: [
|
|
112
|
+
{
|
|
113
|
+
type: "modify",
|
|
114
|
+
target_text: "Foo bar old phrase here.",
|
|
115
|
+
new_text: "X",
|
|
116
|
+
comment: "c",
|
|
117
|
+
}
|
|
118
|
+
],
|
|
119
|
+
output_path: outPath,
|
|
120
|
+
dry_run: true,
|
|
121
|
+
},
|
|
122
|
+
}, 203);
|
|
123
|
+
|
|
124
|
+
expect(res.result).toBeDefined();
|
|
125
|
+
const text = res.result.content[0].text;
|
|
126
|
+
expect(text).toContain("Edit 1 Failed: Target text matches text inside a tracked deletion by Test Negotiator.");
|
|
127
|
+
expect(text).toContain("Reject/accept that change first or target the active replacement text instead.");
|
|
128
|
+
expect(text).not.toContain("Target text not found in document.");
|
|
129
|
+
|
|
130
|
+
// Run Python engine to assert cross-engine exact error parity
|
|
131
|
+
const { execSync } = await import("node:child_process");
|
|
132
|
+
const projectRoot = resolve(__dirname, "../../../..");
|
|
133
|
+
const pythonCli = process.platform === "win32"
|
|
134
|
+
? join(projectRoot, "python/.venv/Scripts/adeu.exe")
|
|
135
|
+
: join(projectRoot, "python/.venv/bin/adeu");
|
|
136
|
+
|
|
137
|
+
// Create temporary changes JSON for Python CLI
|
|
138
|
+
const fs = await import("node:fs");
|
|
139
|
+
const tempJsonPath = join(projectRoot, "tmp", `changes_${Date.now()}.json`);
|
|
140
|
+
fs.mkdirSync(join(projectRoot, "tmp"), { recursive: true });
|
|
141
|
+
fs.writeFileSync(tempJsonPath, JSON.stringify([
|
|
142
|
+
{
|
|
143
|
+
type: "modify",
|
|
144
|
+
target_text: "Foo bar old phrase here.",
|
|
145
|
+
new_text: "X",
|
|
146
|
+
comment: "c",
|
|
147
|
+
}
|
|
148
|
+
]));
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
const pythonOut = execSync(`"${pythonCli}" apply "${fixturePath}" "${tempJsonPath}" --dry-run --author Ed`, {
|
|
152
|
+
encoding: "utf-8",
|
|
153
|
+
});
|
|
154
|
+
// Should not succeed normally since dry run exits with code 1 on errors, so we catch in the try block
|
|
155
|
+
} catch (err: any) {
|
|
156
|
+
const pyErrorOutput = err.stdout || err.stderr || "";
|
|
157
|
+
expect(pyErrorOutput).toContain("Edit 1 Failed: Target text matches text inside a tracked deletion by Test Negotiator.");
|
|
158
|
+
expect(pyErrorOutput).toContain("Reject/accept that change first or target the active replacement text instead.");
|
|
159
|
+
} finally {
|
|
160
|
+
if (fs.existsSync(tempJsonPath)) {
|
|
161
|
+
fs.unlinkSync(tempJsonPath);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("GAP 1 (Live): read_docx mode=outline heading count and content parity with Python", async () => {
|
|
167
|
+
const gap1FixturePath = resolve(__dirname, "../tests/fixtures/gap1_deleted_row_repro.docx");
|
|
168
|
+
const { execSync } = await import("node:child_process");
|
|
169
|
+
const projectRoot = resolve(__dirname, "../../../..");
|
|
170
|
+
const pythonCli = process.platform === "win32"
|
|
171
|
+
? join(projectRoot, "python/.venv/Scripts/adeu.exe")
|
|
172
|
+
: join(projectRoot, "python/.venv/bin/adeu");
|
|
173
|
+
|
|
174
|
+
// 1. clean_view = true
|
|
175
|
+
const nodeResClean = await sendRpc("tools/call", {
|
|
176
|
+
name: "read_docx",
|
|
177
|
+
arguments: {
|
|
178
|
+
file_path: gap1FixturePath,
|
|
179
|
+
mode: "outline",
|
|
180
|
+
clean_view: true,
|
|
181
|
+
},
|
|
182
|
+
}, 204);
|
|
183
|
+
|
|
184
|
+
expect(nodeResClean.result).toBeDefined();
|
|
185
|
+
const nodeTextClean = nodeResClean.result.content[0].text;
|
|
186
|
+
|
|
187
|
+
// Get Python output for clean_view = true
|
|
188
|
+
const pythonOutClean = execSync(`"${pythonCli}" extract "${gap1FixturePath}" --mode outline --clean-view`, {
|
|
189
|
+
encoding: "utf-8",
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Extract raw markdown headings (lines starting with #) to assert perfect parity
|
|
193
|
+
const getHeadings = (text: string) => text.split("\n").filter(line => line.startsWith("#")).map(l => l.trim());
|
|
194
|
+
|
|
195
|
+
const nodeHeadingsClean = getHeadings(nodeTextClean);
|
|
196
|
+
const pythonHeadingsClean = getHeadings(pythonOutClean);
|
|
197
|
+
|
|
198
|
+
expect(nodeHeadingsClean).toEqual(["# Active Heading (p1)"]);
|
|
199
|
+
expect(pythonHeadingsClean).toEqual(["# Active Heading (p1)"]);
|
|
200
|
+
expect(nodeHeadingsClean).toEqual(pythonHeadingsClean);
|
|
201
|
+
|
|
202
|
+
// 2. clean_view = false
|
|
203
|
+
const nodeResDirty = await sendRpc("tools/call", {
|
|
204
|
+
name: "read_docx",
|
|
205
|
+
arguments: {
|
|
206
|
+
file_path: gap1FixturePath,
|
|
207
|
+
mode: "outline",
|
|
208
|
+
clean_view: false,
|
|
209
|
+
},
|
|
210
|
+
}, 205);
|
|
211
|
+
|
|
212
|
+
expect(nodeResDirty.result).toBeDefined();
|
|
213
|
+
const nodeTextDirty = nodeResDirty.result.content[0].text;
|
|
214
|
+
|
|
215
|
+
const pythonOutDirty = execSync(`"${pythonCli}" extract "${gap1FixturePath}" --mode outline`, {
|
|
216
|
+
encoding: "utf-8",
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const nodeHeadingsDirty = getHeadings(nodeTextDirty);
|
|
220
|
+
const pythonHeadingsDirty = getHeadings(pythonOutDirty);
|
|
221
|
+
|
|
222
|
+
expect(nodeHeadingsDirty).toEqual(["# Active Heading (p1)", "# Deleted Heading (p1)"]);
|
|
223
|
+
expect(pythonHeadingsDirty).toEqual(["# Active Heading (p1)", "# Deleted Heading (p1)"]);
|
|
224
|
+
expect(nodeHeadingsDirty).toEqual(pythonHeadingsDirty);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("GAP 1 - Style Def (Live): style-definition outlineLvl on non-heading style classification parity", async () => {
|
|
228
|
+
const gap1FixturePath = resolve(__dirname, "../tests/fixtures/gap1_minimal_repro.docx");
|
|
229
|
+
const { execSync } = await import("node:child_process");
|
|
230
|
+
const projectRoot = resolve(__dirname, "../../../..");
|
|
231
|
+
const pythonCli = process.platform === "win32"
|
|
232
|
+
? join(projectRoot, "python/.venv/Scripts/adeu.exe")
|
|
233
|
+
: join(projectRoot, "python/.venv/bin/adeu");
|
|
234
|
+
|
|
235
|
+
// 1. clean_view = true (both engines must return exactly 2 headings, excluding Subtitle)
|
|
236
|
+
const nodeResClean = await sendRpc("tools/call", {
|
|
237
|
+
name: "read_docx",
|
|
238
|
+
arguments: {
|
|
239
|
+
file_path: gap1FixturePath,
|
|
240
|
+
mode: "outline",
|
|
241
|
+
clean_view: true,
|
|
242
|
+
},
|
|
243
|
+
}, 206);
|
|
244
|
+
|
|
245
|
+
expect(nodeResClean.result).toBeDefined();
|
|
246
|
+
const nodeTextClean = nodeResClean.result.content[0].text;
|
|
247
|
+
|
|
248
|
+
const pythonOutClean = execSync(`"${pythonCli}" extract "${gap1FixturePath}" --mode outline --clean-view`, {
|
|
249
|
+
encoding: "utf-8",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const getHeadings = (text: string) => text.split("\n").filter(line => line.startsWith("#")).map(l => l.trim());
|
|
253
|
+
|
|
254
|
+
const nodeHeadingsClean = getHeadings(nodeTextClean);
|
|
255
|
+
const pythonHeadingsClean = getHeadings(pythonOutClean);
|
|
256
|
+
|
|
257
|
+
// Assert exact count parity (must be exactly 2 for both)
|
|
258
|
+
expect(nodeHeadingsClean.length).toBe(2);
|
|
259
|
+
expect(pythonHeadingsClean.length).toBe(2);
|
|
260
|
+
|
|
261
|
+
expect(nodeHeadingsClean).toEqual(["# Real Heading One (p1)", "# Real Heading Two (p1)"]);
|
|
262
|
+
expect(pythonHeadingsClean).toEqual(["# Real Heading One (p1)", "# Real Heading Two (p1)"]);
|
|
263
|
+
expect(nodeHeadingsClean).toEqual(pythonHeadingsClean);
|
|
264
|
+
});
|
|
265
|
+
});
|
package/src/response-builders.ts
CHANGED
|
@@ -110,6 +110,7 @@ export function build_outline_response(
|
|
|
110
110
|
file_path: string,
|
|
111
111
|
outline_max_level: number = 2,
|
|
112
112
|
outline_verbose: boolean = false,
|
|
113
|
+
paragraph_offsets: Map<any, [number, number]> | null = null,
|
|
113
114
|
): ToolResult {
|
|
114
115
|
const [body] = split_structural_appendix(projected_text);
|
|
115
116
|
const pagination_result = paginate(body, "");
|
|
@@ -119,6 +120,7 @@ export function build_outline_response(
|
|
|
119
120
|
body,
|
|
120
121
|
pagination_result.body_pages,
|
|
121
122
|
pagination_result.body_page_offsets,
|
|
123
|
+
paragraph_offsets,
|
|
122
124
|
);
|
|
123
125
|
|
|
124
126
|
const rendered = render_outline_tree(
|
package/src/tools/email.test.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// FILE: node/packages/mcp-server/src/tools/email.test.ts
|
|
2
1
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
3
2
|
import { search_and_fetch_emails, list_available_mailboxes } from "./email.js";
|
|
4
3
|
|
|
@@ -255,4 +254,108 @@ describe("Node Email Tools Finding #2 and Finding #6 tests", () => {
|
|
|
255
254
|
"You can now use tools like `read_docx`, `diff_docx_files`, or `finalize_document`",
|
|
256
255
|
);
|
|
257
256
|
});
|
|
257
|
+
|
|
258
|
+
describe("Stateful Polling symmetry with Validation", () => {
|
|
259
|
+
const sleepMock = vi.spyOn(global, "setTimeout");
|
|
260
|
+
|
|
261
|
+
beforeEach(() => {
|
|
262
|
+
vi.useFakeTimers();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
afterEach(() => {
|
|
266
|
+
vi.useRealTimers();
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("should handle async task initiation upon searching", async () => {
|
|
270
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
271
|
+
ok: true,
|
|
272
|
+
status: 202,
|
|
273
|
+
json: async () => ({
|
|
274
|
+
status: "pending",
|
|
275
|
+
task_id: "email_task_typescript_123",
|
|
276
|
+
message: "Queued"
|
|
277
|
+
}),
|
|
278
|
+
} as Response);
|
|
279
|
+
|
|
280
|
+
const result = await search_and_fetch_emails({ subject: "heavy search" });
|
|
281
|
+
|
|
282
|
+
expect(result.content[0].text).toContain("Email processing task started successfully");
|
|
283
|
+
expect(result.content[0].text).toContain("task_id=email_task_typescript_123");
|
|
284
|
+
expect(result.structuredContent?.status).toBe("pending");
|
|
285
|
+
expect(result.structuredContent?.task_id).toBe("email_task_typescript_123");
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("should poll and resolve when a task completes successfully", async () => {
|
|
289
|
+
let callCount = 0;
|
|
290
|
+
global.fetch = vi.fn().mockImplementation(async () => {
|
|
291
|
+
callCount++;
|
|
292
|
+
if (callCount === 1) {
|
|
293
|
+
return {
|
|
294
|
+
ok: true,
|
|
295
|
+
status: 200,
|
|
296
|
+
json: async () => ({ status: "PENDING" }),
|
|
297
|
+
} as Response;
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
ok: true,
|
|
301
|
+
status: 200,
|
|
302
|
+
json: async () => ({
|
|
303
|
+
status: "COMPLETED",
|
|
304
|
+
type: "previews",
|
|
305
|
+
previews: [],
|
|
306
|
+
}),
|
|
307
|
+
} as Response;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const promise = search_and_fetch_emails({ task_id: "email_task_typescript_123" });
|
|
311
|
+
promise.catch(() => {}); // Suppress Vitest's unhandled rejection warnings during timer advance
|
|
312
|
+
await vi.runAllTimersAsync();
|
|
313
|
+
|
|
314
|
+
const result = await promise;
|
|
315
|
+
|
|
316
|
+
expect(callCount).toBe(2);
|
|
317
|
+
expect(result.content[0].text).toContain("No emails found matching your search criteria.");
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it("should throw standard ToolError on task failure", async () => {
|
|
321
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
322
|
+
ok: true,
|
|
323
|
+
status: 200,
|
|
324
|
+
json: async () => ({
|
|
325
|
+
status: "FAILED",
|
|
326
|
+
error: "API authorization revoked.",
|
|
327
|
+
}),
|
|
328
|
+
} as Response);
|
|
329
|
+
|
|
330
|
+
const promise = search_and_fetch_emails({ task_id: "email_task_typescript_123" });
|
|
331
|
+
const caughtPromise = promise.catch((err) => err);
|
|
332
|
+
await vi.runAllTimersAsync();
|
|
333
|
+
|
|
334
|
+
await expect(promise).rejects.toThrowError(
|
|
335
|
+
"Validation task failed on the server: API authorization revoked."
|
|
336
|
+
);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("should gracefully return a pending status on polling timeout (50s)", async () => {
|
|
340
|
+
global.fetch = vi.fn().mockResolvedValue({
|
|
341
|
+
ok: true,
|
|
342
|
+
status: 200,
|
|
343
|
+
json: async () => ({ status: "PENDING" }),
|
|
344
|
+
} as Response);
|
|
345
|
+
|
|
346
|
+
const promise = search_and_fetch_emails({ task_id: "email_task_typescript_123" });
|
|
347
|
+
|
|
348
|
+
// Advance all 10 polling intervals
|
|
349
|
+
for (let i = 0; i < 10; i++) {
|
|
350
|
+
await vi.advanceTimersByTimeAsync(5000);
|
|
351
|
+
}
|
|
352
|
+
await vi.runAllTimersAsync();
|
|
353
|
+
|
|
354
|
+
const result = await promise;
|
|
355
|
+
expect(result.content[0].text).toContain("is still processing");
|
|
356
|
+
expect(result.content[0].text).toContain("task_id=email_task_typescript_123");
|
|
357
|
+
expect(result.structuredContent?.status).toBe("pending");
|
|
358
|
+
expect(result.structuredContent?.task_id).toBe("email_task_typescript_123");
|
|
359
|
+
});
|
|
360
|
+
});
|
|
258
361
|
});
|