omnifocus-mcp
Version:
Model Context Protocol (MCP) server that integrates with OmniFocus for AI assistant interaction
353 lines (348 loc) • 14.9 kB
JavaScript
import { exec } from "child_process";
import { promisify } from "util";
import { writeFileSync, unlinkSync, readFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { fileURLToPath } from "url";
import { dirname } from "path";
import { existsSync } from "fs";
import { JXA_FORMAT_DATE_SOURCE } from './dateSerialization.js';
const execAsync = promisify(exec);
const MAX_BUFFER = 10 * 1024 * 1024; // 10MB — default 1MB is too small for large OmniFocus databases
let _logger = null;
export function setScriptLogger(logger) {
_logger = logger;
}
// --- osascript contention control (issue #80, problem B) -----------------------
// OmniFocus.app is a single-threaded shared resource. Under concurrency it
// returns `AppleEvent timed out (-1712)` and, at the default timeout, hangs each
// caller for ~120s. We can't fix cross-process contention from inside one server,
// but we can stop a single server from making it worse and fail fast instead of
// hanging: (1) bound how many osascript children we run at once, (2) impose a
// Node-level timeout that kills a stuck osascript, (3) retry timeouts with
// backoff — but ONLY for idempotent reads. A -1712 on a write may have partially
// applied, so retrying a create could duplicate; writes fail fast and honestly.
function resolvePositiveIntEnv(name, fallback) {
const raw = process.env[name];
if (raw === undefined || raw.trim() === '')
return fallback;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
}
const MAX_CONCURRENT_OSASCRIPT = resolvePositiveIntEnv('OMNIFOCUS_MCP_MAX_CONCURRENT_OSASCRIPT', 4);
const OSASCRIPT_TIMEOUT_MS = resolvePositiveIntEnv('OMNIFOCUS_MCP_OSASCRIPT_TIMEOUT_MS', 60_000);
/** Minimal FIFO semaphore bounding concurrent work. */
export class Semaphore {
max;
active = 0;
queue = [];
constructor(max) {
this.max = max;
}
async run(fn) {
if (this.active >= this.max) {
await new Promise((resolve) => this.queue.push(resolve));
}
this.active++;
try {
return await fn();
}
finally {
this.active--;
this.queue.shift()?.();
}
}
}
const osascriptSemaphore = new Semaphore(MAX_CONCURRENT_OSASCRIPT);
export function classifyOsascriptError(err) {
const e = err;
if (e?.killed === true || e?.signal === 'SIGTERM')
return 'client-kill';
const text = `${e?.message ?? ''} ${e?.stderr ?? ''}`;
if (text.includes('-1712') || text.includes('AppleEvent timed out') || text.includes('timed out')) {
return 'app-timeout';
}
return 'other';
}
/**
* The union of both transient classes.
*
* Retained for callers that just want "was this transient?", but do NOT use it
* as a retry gate: retrying a `client-kill` is what amplifies a wedge (#121).
* Gate on `classifyOsascriptError(err) === 'app-timeout'` instead.
*/
export function isRetryableOsascriptError(err) {
return classifyOsascriptError(err) !== 'other';
}
// --- app health circuit breaker (issue #121) ----------------------------------
// Once the app has failed to answer at all, further dispatches make things
// strictly worse. Hold that state process-wide so queued queries fail fast
// instead of each burning a full timeout (and each leaving another orphaned
// queue) against a target already known to be dead.
const UNRESPONSIVE_COOLDOWN_MS = resolvePositiveIntEnv('OMNIFOCUS_MCP_UNRESPONSIVE_COOLDOWN_MS', 30_000);
const PROBE_TIMEOUT_MS = resolvePositiveIntEnv('OMNIFOCUS_MCP_PROBE_TIMEOUT_MS', 5_000);
export class AppUnresponsiveError extends Error {
constructor(message) {
super(message);
this.name = 'AppUnresponsiveError';
}
}
const UNRESPONSIVE_MESSAGE = 'OmniFocus is not responding to AppleEvents. Requests are paused to avoid making it worse. ' +
'If this persists, quit every omnifocus-mcp process and then restart OmniFocus (in that order).';
let unresponsiveSince = null;
/** Test hook: clear the breaker. */
export function _resetAppHealth() {
unresponsiveSince = null;
}
export function markAppUnresponsive(now = Date.now()) {
if (unresponsiveSince === null) {
_logger?.error('scriptExecution', 'OmniFocus did not answer before the timeout; pausing dispatch (issue #121).');
}
unresponsiveSince = now;
}
export function markAppResponsive() {
if (unresponsiveSince !== null) {
_logger?.info('scriptExecution', 'OmniFocus is answering again; resuming dispatch.');
}
unresponsiveSince = null;
}
/** True while the breaker is open and still inside its cooldown. */
export function isAppKnownUnresponsive(now = Date.now()) {
return unresponsiveSince !== null && now - unresponsiveSince < UNRESPONSIVE_COOLDOWN_MS;
}
/** True when the breaker is open but the cooldown has elapsed — probe once. */
export function shouldProbeAppHealth(now = Date.now()) {
return unresponsiveSince !== null && now - unresponsiveSince >= UNRESPONSIVE_COOLDOWN_MS;
}
/**
* One cheap AppleEvent with a short timeout, used to decide whether the app has
* recovered. Deliberately does NOT go through the semaphore: if wedged queries
* are holding every slot, the probe must still be able to run. It is also the
* only thing we send while the breaker is open, so at most one transaction per
* cooldown is risked against a possibly-wedged app.
*/
export async function probeOmniFocusAlive(execFn = execAsync, timeoutMs = PROBE_TIMEOUT_MS) {
try {
await execFn(`osascript -e 'tell application "OmniFocus" to get name of default document'`, { timeout: timeoutMs, killSignal: 'SIGTERM', maxBuffer: 64 * 1024 });
return true;
}
catch {
return false;
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/**
* Run `attempt`, retrying with backoff while `shouldRetry(err)` holds. Extracted
* (with injectable sleep) so the retry policy is unit-testable without osascript.
*/
export async function withOsascriptRetry(attempt, opts) {
// Backoffs lengthened from [500, 1500] (#121): sub-2s gaps gave a contended
// app no room to drain before the next full query landed on it.
const backoffs = opts.backoffsMs ?? [2000, 6000];
const doSleep = opts.sleepFn ?? sleep;
let i = 0;
for (;;) {
try {
return await attempt();
}
catch (err) {
if (i >= backoffs.length || !opts.shouldRetry(err))
throw err;
_logger?.error('scriptExecution', `osascript retryable failure (attempt ${i + 1}); retrying`);
await doSleep(backoffs[i]);
i++;
}
}
}
/**
* Execute an osascript file behind the shared concurrency gate with a hard
* timeout, optionally retrying transient timeouts. All osascript invocations in
* the server route through here so the bounds actually apply globally per-process.
*/
export async function runOsascriptFile(tempFile, options = {}) {
const { language, maxBuffer = MAX_BUFFER, timeoutMs = OSASCRIPT_TIMEOUT_MS, retryOnTimeout = false, } = options;
const langFlag = language === 'JavaScript' ? '-l JavaScript ' : '';
const cmd = `osascript ${langFlag}"${tempFile}"`;
// Circuit breaker (#121). While the app is known unresponsive, dispatch
// nothing at all — every osascript we send and then kill leaves a permanently
// blocked AppleEvent queue inside the app. Failing fast here is not just
// faster, it avoids inflicting further damage.
if (isAppKnownUnresponsive()) {
throw new AppUnresponsiveError(UNRESPONSIVE_MESSAGE);
}
if (shouldProbeAppHealth()) {
if (await probeOmniFocusAlive()) {
markAppResponsive();
}
else {
markAppUnresponsive();
throw new AppUnresponsiveError(UNRESPONSIVE_MESSAGE);
}
}
const attempt = () => osascriptSemaphore.run(() => execAsync(cmd, { maxBuffer, timeout: timeoutMs, killSignal: 'SIGTERM' }));
// A client-kill means the app never answered. Do not retry it — trip the
// breaker and surface an honest error instead. Only an app-reported timeout
// (-1712), which proves the app is alive and merely contended, is retried.
const run = async () => {
try {
const out = retryOnTimeout
? await withOsascriptRetry(attempt, {
shouldRetry: (err) => classifyOsascriptError(err) === 'app-timeout',
})
: await attempt();
markAppResponsive();
return out;
}
catch (err) {
if (classifyOsascriptError(err) === 'client-kill') {
markAppUnresponsive();
throw new AppUnresponsiveError(UNRESPONSIVE_MESSAGE);
}
throw err;
}
};
return run();
}
// Helper function to execute OmniFocus scripts
export async function executeJXA(script) {
const start = Date.now();
try {
// Write the script to a temporary file in the system temp directory
const tempFile = join(tmpdir(), `jxa_script_${crypto.randomUUID()}.js`);
// Write the script to the temporary file
writeFileSync(tempFile, script);
_logger?.debug("scriptExecution", "Executing JXA script");
// Execute the script using osascript (read path — safe to retry on -1712)
const { stdout, stderr } = await runOsascriptFile(tempFile, {
language: 'JavaScript',
retryOnTimeout: true,
});
if (stderr) {
console.error("Script stderr output:", stderr);
}
// Clean up the temporary file
unlinkSync(tempFile);
const elapsed = Date.now() - start;
_logger?.debug("scriptExecution", `JXA script completed in ${elapsed}ms`);
// Parse the output as JSON
try {
const result = JSON.parse(stdout);
return result;
}
catch (e) {
console.error("Failed to parse script output as JSON:", e);
// If this contains a "Found X tasks" message, treat it as a successful non-JSON response
if (stdout.includes("Found") && stdout.includes("tasks")) {
return [];
}
return [];
}
}
catch (error) {
const elapsed = Date.now() - start;
_logger?.error("scriptExecution", `JXA script failed after ${elapsed}ms: ${error}`);
console.error("Failed to execute JXA script:", error);
throw error;
}
}
const escapeContent = (content) => {
return content
.replace(/\\/g, "\\\\") // Escape backslashes
.replace(/`/g, "\\`") // Escape backticks
.replace(/\$/g, "\\$"); // Escape dollar signs
};
// Function to execute scripts in OmniFocus using the URL scheme
export async function executeOmniFocusScript(scriptPath, args) {
const start = Date.now();
try {
// Get the actual script path
let actualPath;
if (scriptPath.startsWith("@")) {
const scriptName = scriptPath.substring(1);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const distPath = join(__dirname, "..", "utils", "omnifocusScripts", scriptName);
const srcPath = join(__dirname, "..", "..", "src", "utils", "omnifocusScripts", scriptName);
if (existsSync(distPath)) {
actualPath = distPath;
}
else if (existsSync(srcPath)) {
actualPath = srcPath;
}
else {
actualPath = join(__dirname, "..", "omnifocusScripts", scriptName);
}
}
else {
actualPath = scriptPath;
}
// Read the script file
const scriptContent = readFileSync(actualPath, "utf8");
// Create a wrapper script that sets up arguments and executes the original script.
//
// Every payload gets `formatDate` from the canonical implementation (#91) instead
// of declaring its own. The payloads each used to carry a `toISOString()` copy,
// which meant a timezone fix had to be applied in N places or silently only
// half-land. Payloads must NOT declare `formatDate` themselves — a `function`
// declaration alongside this `const` is a redeclaration SyntaxError, which the
// prelude test asserts against.
let wrappedScript = `${JXA_FORMAT_DATE_SOURCE}\n\n${scriptContent}`;
if (args && args.length > 0) {
const quotedArgs = args
.map((arg) => `"${escapeContent(arg)}"`)
.join(", ");
wrappedScript = `${JXA_FORMAT_DATE_SOURCE}
// Set up arguments
const argv = [${quotedArgs}];
${scriptContent}`;
}
// Create a temporary file for our JXA wrapper script
const tempFile = join(tmpdir(), `jxa_wrapper_${crypto.randomUUID()}.js`);
// Escape the script content properly for use in JXA
const escapedScript = escapeContent(wrappedScript);
// Create a JXA script that will execute our OmniJS script in OmniFocus
const jxaScript = `
function run() {
try {
const app = Application('OmniFocus');
app.includeStandardAdditions = true;
// Run the OmniJS script in OmniFocus and capture the output
const result = app.evaluateJavascript(\`${escapedScript}\`);
// Return the result
return result;
} catch (e) {
return JSON.stringify({ error: e.message });
}
}
`;
_logger?.debug("scriptExecution", `Executing OmniFocus script: ${scriptPath}`);
// Write the JXA script to the temporary file
writeFileSync(tempFile, jxaScript);
// Execute the JXA script using osascript (read path — safe to retry on -1712)
const { stdout, stderr } = await runOsascriptFile(tempFile, {
language: 'JavaScript',
retryOnTimeout: true,
});
// Clean up the temporary file
unlinkSync(tempFile);
if (stderr) {
console.error("Script stderr output:", stderr);
}
const elapsed = Date.now() - start;
_logger?.debug("scriptExecution", `OmniFocus script completed in ${elapsed}ms`);
// Parse the output as JSON
try {
return JSON.parse(stdout);
}
catch (parseError) {
console.error("Error parsing script output:", parseError);
return stdout;
}
}
catch (error) {
const elapsed = Date.now() - start;
_logger?.error("scriptExecution", `OmniFocus script failed after ${elapsed}ms: ${error}`);
console.error("Failed to execute OmniFocus script:", error);
throw error;
}
}