apple-mail-mcp 1.5.6 → 1.5.7
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/build/index.js
CHANGED
|
@@ -24,6 +24,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
24
24
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
25
25
|
import { z } from "zod";
|
|
26
26
|
import { AppleMailManager } from "./services/appleMailManager.js";
|
|
27
|
+
import { createSerialGate } from "./utils/serialize.js";
|
|
27
28
|
// =============================================================================
|
|
28
29
|
// Shared Validation Schemas
|
|
29
30
|
// =============================================================================
|
|
@@ -84,17 +85,30 @@ function errorResponse(message) {
|
|
|
84
85
|
};
|
|
85
86
|
}
|
|
86
87
|
/**
|
|
87
|
-
*
|
|
88
|
+
* Serial execution gate for AppleScript-backed tool calls (issue #11).
|
|
89
|
+
*
|
|
90
|
+
* Concurrent MCP tool calls are funneled through this single gate so only one
|
|
91
|
+
* osascript invocation hits Mail.app's single-threaded AppleScript dispatch at
|
|
92
|
+
* a time, with a short settle delay between calls so the dispatch queue drains.
|
|
93
|
+
* Without it, a concurrent batch races into Mail.app, the later calls blow past
|
|
94
|
+
* their timeouts, and Mail.app is left half-recovered for the next batch.
|
|
95
|
+
*/
|
|
96
|
+
const serializeAppleScript = createSerialGate();
|
|
97
|
+
/**
|
|
98
|
+
* Wraps a tool handler with consistent error handling, serialized through the
|
|
99
|
+
* AppleScript gate so concurrent MCP tool calls don't race into Mail.app (#11).
|
|
88
100
|
*/
|
|
89
101
|
function withErrorHandling(handler, errorPrefix) {
|
|
90
102
|
return async (params) => {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
103
|
+
return serializeAppleScript(() => {
|
|
104
|
+
try {
|
|
105
|
+
return handler(params);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
109
|
+
return errorResponse(`${errorPrefix}: ${message}`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
98
112
|
};
|
|
99
113
|
}
|
|
100
114
|
// =============================================================================
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"applescript.d.ts","sourceRoot":"","sources":["../../src/utils/applescript.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"applescript.d.ts","sourceRoot":"","sources":["../../src/utils/applescript.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,YAAY,CAAC;AAmQxE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,kBAAkB,CAChC,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,kBAAuB,GAC/B,iBAAiB,CAoInB"}
|
|
@@ -65,6 +65,32 @@ function escapeForShell(script) {
|
|
|
65
65
|
// This is the standard shell escaping pattern for single-quoted strings
|
|
66
66
|
return script.replace(/'/g, "'\\''");
|
|
67
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Headroom (ms) between the in-AppleScript `with timeout` and the outer
|
|
70
|
+
* osascript process timeout. The script-level timeout must fire *first* so
|
|
71
|
+
* Mail.app aborts the operation from inside its own AppleScript dispatch —
|
|
72
|
+
* cleanly releasing the event queue — before Node SIGKILLs the osascript
|
|
73
|
+
* process. Killing osascript alone does not stop work already dispatched into
|
|
74
|
+
* Mail.app, which is what wedges Mail.app for subsequent calls (issue #11).
|
|
75
|
+
*/
|
|
76
|
+
const SCRIPT_TIMEOUT_HEADROOM_MS = 5000;
|
|
77
|
+
/**
|
|
78
|
+
* Wraps a script body in an AppleScript `with timeout` block so any Apple
|
|
79
|
+
* Event that honors timeouts (most Mail.app operations do) aborts cleanly
|
|
80
|
+
* rather than holding Mail.app's single-threaded AppleScript dispatch queue
|
|
81
|
+
* open indefinitely.
|
|
82
|
+
*
|
|
83
|
+
* The timeout is set a few seconds below the osascript process timeout so the
|
|
84
|
+
* in-Mail abort wins the race against the outer process kill.
|
|
85
|
+
*
|
|
86
|
+
* @param script - The AppleScript body
|
|
87
|
+
* @param processTimeoutMs - The outer osascript process timeout
|
|
88
|
+
* @returns The script wrapped in `with timeout … end timeout`
|
|
89
|
+
*/
|
|
90
|
+
function wrapWithTimeout(script, processTimeoutMs) {
|
|
91
|
+
const seconds = Math.max(1, Math.ceil((processTimeoutMs - SCRIPT_TIMEOUT_HEADROOM_MS) / 1000));
|
|
92
|
+
return `with timeout of ${seconds} seconds\n${script}\nend timeout`;
|
|
93
|
+
}
|
|
68
94
|
/**
|
|
69
95
|
* Checks if an error is a timeout error from execSync.
|
|
70
96
|
*
|
|
@@ -270,8 +296,10 @@ export function executeAppleScript(script, options = {}) {
|
|
|
270
296
|
// Prepare the script:
|
|
271
297
|
// 1. Trim leading/trailing whitespace (cosmetic)
|
|
272
298
|
// 2. Preserve internal newlines (required for AppleScript syntax)
|
|
273
|
-
// 3.
|
|
274
|
-
|
|
299
|
+
// 3. Wrap in `with timeout` so a stuck Mail.app operation aborts from
|
|
300
|
+
// inside its own dispatch before the process timeout kills osascript (#11)
|
|
301
|
+
// 4. Escape for shell execution
|
|
302
|
+
const preparedScript = escapeForShell(wrapWithTimeout(script.trim(), timeoutMs));
|
|
275
303
|
// Build the osascript command
|
|
276
304
|
// We use single quotes to wrap the script, which is why we escape
|
|
277
305
|
// single quotes within the script itself
|
|
@@ -292,6 +320,11 @@ export function executeAppleScript(script, options = {}) {
|
|
|
292
320
|
const output = execSync(command, {
|
|
293
321
|
encoding: "utf8",
|
|
294
322
|
timeout: timeoutMs,
|
|
323
|
+
// SIGKILL (not the default SIGTERM): a wedged osascript blocked on an
|
|
324
|
+
// unresponsive Mail.app can ignore SIGTERM, leaking processes that pile
|
|
325
|
+
// up and worsen the contention. SIGKILL guarantees the process is reaped
|
|
326
|
+
// when the timeout fires. (#11)
|
|
327
|
+
killSignal: "SIGKILL",
|
|
295
328
|
// Capture stderr separately to get error details
|
|
296
329
|
stdio: ["pipe", "pipe", "pipe"],
|
|
297
330
|
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serial execution gate.
|
|
3
|
+
*
|
|
4
|
+
* Mail.app's AppleScript handler is effectively single-threaded: concurrent
|
|
5
|
+
* `tell application "Mail"` calls queue inside Mail.app, and once enough stack
|
|
6
|
+
* up the later ones blow past their timeouts while earlier ones are still
|
|
7
|
+
* draining — the client sees a cascade of "Request timed out" errors and
|
|
8
|
+
* Mail.app is left half-recovered for the next batch (issue #11).
|
|
9
|
+
*
|
|
10
|
+
* A gate chains every task through a single promise so only one runs at a time,
|
|
11
|
+
* with an optional settle delay between tasks so Mail.app's dispatch queue can
|
|
12
|
+
* drain. Because tasks are chained on promises, awaiting the gate also yields
|
|
13
|
+
* the event loop between calls, keeping the server responsive to protocol
|
|
14
|
+
* traffic (pings, health-check) during a batch.
|
|
15
|
+
*
|
|
16
|
+
* @module utils/serialize
|
|
17
|
+
*/
|
|
18
|
+
/** A function that serializes a task behind all previously enqueued tasks. */
|
|
19
|
+
export type SerialGate = <R>(task: () => R | PromiseLike<R>) => Promise<R>;
|
|
20
|
+
/**
|
|
21
|
+
* Creates a serial execution gate.
|
|
22
|
+
*
|
|
23
|
+
* @param settleMs - Delay inserted after each task before the next one starts
|
|
24
|
+
* (default 50ms). Set to 0 to disable.
|
|
25
|
+
* @returns A `serialize(task)` function. Tasks run strictly in enqueue order and
|
|
26
|
+
* never overlap; each call resolves/rejects with its task's own result, and a
|
|
27
|
+
* failing task never breaks serialization for later tasks.
|
|
28
|
+
*/
|
|
29
|
+
export declare function createSerialGate(settleMs?: number): SerialGate;
|
|
30
|
+
//# sourceMappingURL=serialize.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"serialize.d.ts","sourceRoot":"","sources":["../../src/utils/serialize.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,8EAA8E;AAC9E,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,EAAE,IAAI,EAAE,MAAM,CAAC,GAAG,WAAW,CAAC,CAAC,CAAC,KAAK,OAAO,CAAC,CAAC,CAAC,CAAC;AAE3E;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,QAAQ,SAAK,GAAG,UAAU,CAa1D"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Serial execution gate.
|
|
3
|
+
*
|
|
4
|
+
* Mail.app's AppleScript handler is effectively single-threaded: concurrent
|
|
5
|
+
* `tell application "Mail"` calls queue inside Mail.app, and once enough stack
|
|
6
|
+
* up the later ones blow past their timeouts while earlier ones are still
|
|
7
|
+
* draining — the client sees a cascade of "Request timed out" errors and
|
|
8
|
+
* Mail.app is left half-recovered for the next batch (issue #11).
|
|
9
|
+
*
|
|
10
|
+
* A gate chains every task through a single promise so only one runs at a time,
|
|
11
|
+
* with an optional settle delay between tasks so Mail.app's dispatch queue can
|
|
12
|
+
* drain. Because tasks are chained on promises, awaiting the gate also yields
|
|
13
|
+
* the event loop between calls, keeping the server responsive to protocol
|
|
14
|
+
* traffic (pings, health-check) during a batch.
|
|
15
|
+
*
|
|
16
|
+
* @module utils/serialize
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* Creates a serial execution gate.
|
|
20
|
+
*
|
|
21
|
+
* @param settleMs - Delay inserted after each task before the next one starts
|
|
22
|
+
* (default 50ms). Set to 0 to disable.
|
|
23
|
+
* @returns A `serialize(task)` function. Tasks run strictly in enqueue order and
|
|
24
|
+
* never overlap; each call resolves/rejects with its task's own result, and a
|
|
25
|
+
* failing task never breaks serialization for later tasks.
|
|
26
|
+
*/
|
|
27
|
+
export function createSerialGate(settleMs = 50) {
|
|
28
|
+
let tail = Promise.resolve();
|
|
29
|
+
return (task) => {
|
|
30
|
+
const result = tail.then(task);
|
|
31
|
+
// Keep the chain alive regardless of this task's outcome, with a settle
|
|
32
|
+
// delay so the next task doesn't race straight back into Mail.app.
|
|
33
|
+
tail = result.then(() => settle(settleMs), () => settle(settleMs));
|
|
34
|
+
return result;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function settle(ms) {
|
|
38
|
+
if (ms <= 0)
|
|
39
|
+
return Promise.resolve();
|
|
40
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
41
|
+
}
|