UNPKG

omnifocus-mcp

Version:

Model Context Protocol (MCP) server that integrates with OmniFocus for AI assistant interaction

266 lines (265 loc) 11.4 kB
import { connect } from 'net'; import { spawn } from 'child_process'; import { mkdirSync, openSync } from 'fs'; import { dirname, join } from 'path'; import { fileURLToPath } from 'url'; import { resolveSocketPath, resolveLockDir, SOCKET_DIR_MODE } from './socketPath.js'; import { tryAcquireLock } from './lock.js'; import { resolveIdleTimeoutMinutes, installIdleTimeout } from '../utils/idleTimeout.js'; import { SessionTracker } from './sessionReplay.js'; /** * The client-facing half of the daemon (issue #80). * * This is what an MCP client launches. It speaks no protocol of its own: it * connects to the shared daemon and splices stdin/stdout onto that socket. MCP * over stdio is newline-delimited JSON-RPC with no per-connection framing beyond * the newline, so a byte-for-byte pipe is a complete implementation. * * Why a shim at all, rather than pointing clients at the daemon directly? Because * every MCP client in existence knows how to launch a command and talk to its * pipes, and almost none know how to talk to a Unix socket. Keeping the launch * contract identical is what lets this ship without every user editing config. * * The shim is ~1 MB of resident node instead of a full server holding an * OmniFocus bridge, and — the actual point — it holds no osascript semaphore, so * ten clients no longer mean ten independent concurrency budgets aimed at one * single-threaded app. */ /** How long to wait for a freshly spawned daemon to start accepting. */ export const DAEMON_START_TIMEOUT_MS = 10_000; const CONNECT_RETRY_INTERVAL_MS = 50; /** How many daemon restarts one shim will transparently survive (#123). */ const MAX_RECONNECTS = 5; /** * Deliberately does NOT unref the timer. While we wait for the daemon to come * up, this timer is the only referenced handle in the process — stdin is still * paused and no socket is open yet. An unref'd timer here let the event loop * drain, and node exited 0 mid-wait: the client saw a server that started, * printed nothing, and vanished. Warm connects hid it, because they return on * the first attempt and never reach this. */ function sleep(ms) { return new Promise((resolve) => { setTimeout(resolve, ms); }); } /** One connection attempt. Resolves to null on any failure. */ export function tryConnect(socketPath) { return new Promise((resolve) => { const socket = connect(socketPath); const onError = () => { socket.destroy(); resolve(null); }; socket.once('error', onError); socket.once('connect', () => { socket.removeListener('error', onError); resolve(socket); }); }); } /** Retry until the daemon is accepting or the deadline passes. */ async function connectWithRetry(socketPath, timeoutMs) { const deadline = Date.now() + timeoutMs; for (;;) { const socket = await tryConnect(socketPath); if (socket) return socket; if (Date.now() >= deadline) return null; await sleep(CONNECT_RETRY_INTERVAL_MS); } } /** * Launch the daemon as a detached, fully orphan-proof background process. * * `detached` + `unref` are what let this shim exit without killing the daemon — * it has to outlive the client that happened to start it. Its stderr goes to a * log file rather than being inherited: inheriting would tie the daemon's fate to * the first client's terminal, and a write to a closed pipe after that client * dies would take the daemon down with EPIPE, taking every other session with it. */ function defaultSpawnDaemon(socketPath) { const socketDir = dirname(socketPath); mkdirSync(socketDir, { recursive: true, mode: SOCKET_DIR_MODE }); const logFd = openSync(join(socketDir, 'daemon.log'), 'a', 0o600); const daemonEntry = fileURLToPath(new URL('./main.js', import.meta.url)); const child = spawn(process.execPath, [daemonEntry], { detached: true, stdio: ['ignore', logFd, logFd], env: { ...process.env, OMNIFOCUS_MCP_SOCKET: socketPath, // We hold the startup lock; hand the critical section to the child so it // doesn't deadlock waiting on its own parent. OMNIFOCUS_MCP_LOCK_HELD: '1', }, }); child.unref(); } /** * Obtain a daemon connection: connect if one is running, otherwise start one. * * The lock is what keeps ten simultaneous cold starts from spawning ten daemons. * Whoever takes it spawns; everyone else skips straight to waiting, because a * daemon is demonstrably on its way. Losing the race is the common path, not the * exceptional one — this project's normal load is a burst of agents at once. */ export async function obtainConnection(socketPath, spawnDaemon, startTimeoutMs) { const existing = await tryConnect(socketPath); if (existing) return existing; // The lock lives beside the socket, so the directory has to exist before we // can take it — on a fresh install nothing here exists yet. (Cold start failed // to the standalone fallback on every first run until this line was added.) mkdirSync(dirname(socketPath), { recursive: true, mode: SOCKET_DIR_MODE }); const lock = tryAcquireLock(resolveLockDir(socketPath)); if (!lock) return connectWithRetry(socketPath, startTimeoutMs); try { spawnDaemon(socketPath); } catch (err) { lock.release(); console.error(`[omnifocus-mcp] could not start daemon: ${err}`); return null; } // The daemon releases the lock itself once it has bound (it inherited the // critical section), so we must not release it here — doing so would reopen // the probe/unlink/bind race we took the lock to close. return connectWithRetry(socketPath, startTimeoutMs); } /** * Run as a stdio client of the shared daemon, falling back to an in-process * server if the daemon can't be reached. * * The fallback matters more than it looks: this code path is on the critical * path of every user of the package, including ones who will never read issue * #80. If anything about the socket is unusable — a sandbox with no writable * runtime dir, an exotic filesystem, a path length blown past sun_path — the * server must still work exactly as it did before, just without the sharing. */ export async function runShim(options = {}) { const socketPath = options.socketPath ?? resolveSocketPath(); const spawnDaemon = options.spawnDaemon ?? defaultSpawnDaemon; const startTimeoutMs = options.startTimeoutMs ?? DAEMON_START_TIMEOUT_MS; const fallback = options.fallback ?? (async () => { await import('../server.js'); }); let socket = null; try { socket = await obtainConnection(socketPath, spawnDaemon, startTimeoutMs); } catch (err) { console.error(`[omnifocus-mcp] daemon connect failed: ${err}`); } if (!socket) { console.error('[omnifocus-mcp] daemon unavailable; running standalone server.'); await fallback(); return; } // Session state that has to survive a daemon restart (#123). Observing only — // the pipe below stays byte-for-byte. const session = new SessionTracker(); let active = socket; let clientClosed = false; let reconnecting = false; let reconnectsLeft = MAX_RECONNECTS; // Nothing has touched stdin until now, so it is still paused and no client // bytes have been dropped while we were connecting. process.stdin.on('data', (chunk) => { session.observeOutbound(chunk.toString('utf8')); active.write(chunk); }); const attachSocket = (sock) => { active = sock; sock.on('data', (chunk) => { session.observeInbound(chunk.toString('utf8')); process.stdout.write(chunk); }); sock.on('close', onSocketGone); sock.on('error', (err) => { if (err.code !== 'ECONNRESET' && err.code !== 'EPIPE') { console.error(`[omnifocus-mcp] daemon connection error: ${err.message}`); } onSocketGone(); }); }; const exit = () => { active.destroy(); process.exit(0); }; /** * The daemon went away. Historically this exited, on the assumption the client * would relaunch — which is false for clients that treat a stdio exit as * terminal (#123). Try to rebuild the session on a new daemon instead, and only * exit if that cannot be done. */ async function onSocketGone() { if (clientClosed || reconnecting) return; reconnecting = true; try { if (!session.canReplay || reconnectsLeft <= 0) { exit(); return; } reconnectsLeft--; let next = null; try { next = await obtainConnection(socketPath, spawnDaemon, startTimeoutMs); } catch { next = null; } if (!next) { console.error('[omnifocus-mcp] daemon gone and not recoverable; exiting.'); exit(); return; } console.error('[omnifocus-mcp] daemon restarted; re-establishing session (#123).'); attachSocket(next); // Rebuild server-side session state before anything else reaches it. for (const line of session.replayLines()) next.write(line + '\n'); // Fail in-flight requests explicitly rather than letting the client hang // on responses that died with the old daemon. for (const line of session.orphanedResponses()) process.stdout.write(line + '\n'); session.clearPending(); } finally { reconnecting = false; } } // A client that closes its end of the pipes is a disconnect, not a fault. With // no handler, node's default for an 'error' event is to rethrow, so a client // exiting mid-response killed the shim with an EPIPE stack trace on the way // out — noise that looks like a server crash and buries the real cause. const onPipeError = (err) => { clientClosed = true; if (err.code !== 'EPIPE' && err.code !== 'ERR_STREAM_DESTROYED') { console.error(`[omnifocus-mcp] stdio error: ${err.message}`); } exit(); }; process.stdout.on('error', onPipeError); process.stdin.on('error', onPipeError); attachSocket(socket); process.stdin.on('end', () => { clientClosed = true; active.end(); }); process.on('SIGTERM', exit); process.on('SIGHUP', exit); process.on('SIGINT', exit); // Same orphan backstop as the standalone server: if the client is SIGKILLed // and the wrapper chain holds stdin open, no EOF ever arrives. A stranded shim // is far cheaper than a stranded server, but it still pins a daemon session. const idleMinutes = resolveIdleTimeoutMinutes(process.env.OMNIFOCUS_MCP_IDLE_TIMEOUT_MINUTES); installIdleTimeout(process.stdin, idleMinutes, () => { console.error(`[omnifocus-mcp] no client traffic for ${idleMinutes}m; closing daemon session (issue #80).`); exit(); }); }