omnifocus-mcp
Version:
Model Context Protocol (MCP) server that integrates with OmniFocus for AI assistant interaction
201 lines (200 loc) • 8.12 kB
JavaScript
import { createServer, connect } from 'net';
import { mkdirSync, unlinkSync, existsSync } from 'fs';
import { dirname } from 'path';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { createOmniFocusServer } from '../buildServer.js';
import { resolveSocketPath, resolveLockDir, SOCKET_DIR_MODE } from './socketPath.js';
import { tryAcquireLock, releaseLock } from './lock.js';
import { resolveIdleTimeoutMinutes } from '../utils/idleTimeout.js';
/**
* The shared daemon (issue #80).
*
* Problem A (process accumulation) and problem B (AppleEvent contention) both
* come from the same root: every agent spawns its own server, and each one holds
* its own copy of the osascript concurrency bound. Ten agents meant ten servers
* and an effective bound of 10×4 concurrent osascript children hammering one
* single-threaded OmniFocus.app.
*
* Here, one process hosts every session. Sessions are still independent — MCP is
* stateful, so each connection gets its own `McpServer` — but they all route
* through the *same* module-level semaphore in `scriptExecution.ts`, so the bound
* is finally global. That is the substantive fix; collapsing the process count is
* the bonus.
*/
/** Fires `onIdle` once the daemon has had zero connections for `minutes`. */
export class IdleReaper {
minutes;
onIdle;
setTimer;
clearTimer;
timer;
constructor(minutes, onIdle, setTimer = setTimeout, clearTimer = clearTimeout) {
this.minutes = minutes;
this.onIdle = onIdle;
this.setTimer = setTimer;
this.clearTimer = clearTimer;
}
/** Arm the countdown — call when the connection count reaches zero. */
arm() {
if (!(this.minutes > 0))
return;
this.disarm();
this.timer = this.setTimer(this.onIdle, this.minutes * 60_000);
this.timer.unref?.();
}
/** Cancel the countdown — call when a connection arrives. */
disarm() {
if (this.timer !== undefined) {
this.clearTimer(this.timer);
this.timer = undefined;
}
}
get armed() {
return this.timer !== undefined;
}
}
/**
* Is something actually listening on `socketPath`?
*
* A socket file left behind by a killed daemon looks identical to a live one on
* disk, and bind() over it fails with EADDRINUSE either way. The only honest test
* is to try to connect: ECONNREFUSED means stale, success means a daemon owns it.
* Getting this wrong in the other direction is worse than leaving a stale file —
* unlinking a *live* socket silently orphans every connected client — so this
* treats any non-ECONNREFUSED error as "assume live".
*/
export function probeSocket(socketPath, timeoutMs = 1000) {
return new Promise((resolve) => {
if (!existsSync(socketPath))
return resolve(false);
const probe = connect(socketPath);
let settled = false;
const finish = (alive) => {
if (settled)
return;
settled = true;
probe.destroy();
resolve(alive);
};
const timer = setTimeout(() => finish(true), timeoutMs);
timer.unref?.();
probe.on('connect', () => {
clearTimeout(timer);
finish(true);
});
probe.on('error', (err) => {
clearTimeout(timer);
finish(err.code !== 'ECONNREFUSED');
});
});
}
/**
* Bind the socket and serve MCP sessions until idle or terminated.
*
* Rejects with EADDRINUSE if a live daemon already owns the socket — the caller
* (the shim) treats that as "someone beat me to it" and simply connects instead.
*/
export async function startDaemon(options = {}) {
const socketPath = options.socketPath ?? resolveSocketPath();
const idleMinutes = options.idleTimeoutMinutes ??
resolveIdleTimeoutMinutes(process.env.OMNIFOCUS_MCP_IDLE_TIMEOUT_MINUTES);
const lockDir = resolveLockDir(socketPath);
mkdirSync(dirname(socketPath), { recursive: true, mode: SOCKET_DIR_MODE });
// Probe → unlink → bind has to be one critical section. Without a lock there
// is an interleaving where B probes a *stale* socket, A wins the race and
// binds, and then B unlinks A's now-live socket — leaving A running but
// unreachable and every client that finds the missing file spawning another
// daemon. Exactly the pileup issue #80 is about.
//
// The shim takes this lock before spawning, so the child inherits the critical
// section rather than re-contending for it (and would otherwise deadlock
// against its own parent).
const lock = options.lockHeldByCaller
? { release: () => releaseLock(lockDir) }
: (tryAcquireLock(lockDir) ?? throwAddrInUse(`another process is starting a daemon on ${socketPath}`));
try {
// Reclaim a socket whose owner died; never touch one that answers.
if (existsSync(socketPath)) {
if (await probeSocket(socketPath)) {
throwAddrInUse(`a daemon is already listening on ${socketPath}`);
}
try {
unlinkSync(socketPath);
}
catch {
/* raced with another starter; bind will surface the real problem */
}
}
let connections = 0;
const sockets = new Set();
const shutdown = async () => {
reaper.disarm();
for (const socket of sockets)
socket.destroy();
sockets.clear();
await new Promise((resolve) => server.close(() => resolve()));
try {
unlinkSync(socketPath);
}
catch {
/* already gone */
}
};
const reaper = new IdleReaper(idleMinutes, () => {
console.error(`[omnifocus-mcp] daemon idle for ${idleMinutes}m with no connections; exiting (issue #80).`);
void shutdown().then(() => process.exit(0));
});
const handleConnection = (socket) => {
connections++;
sockets.add(socket);
reaper.disarm();
socket.on('close', () => {
connections--;
sockets.delete(socket);
if (connections === 0)
reaper.arm();
});
// A client vanishing mid-request is routine, not an error worth crashing on.
socket.on('error', () => socket.destroy());
if (options.onConnection) {
options.onConnection(socket);
return;
}
// One MCP session per connection; all sessions share the process-global
// osascript semaphore, which is the entire point of the daemon.
const { server: session } = createOmniFocusServer();
const transport = new StdioServerTransport(socket, socket);
session.connect(transport).catch((err) => {
console.error(`[omnifocus-mcp] failed to attach session: ${err}`);
socket.destroy();
});
};
const server = createServer(handleConnection);
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(socketPath, () => {
server.removeListener('error', reject);
resolve();
});
});
// Nothing is connected yet, so start the clock immediately: a daemon that
// was autostarted for a client that then died must not linger forever.
reaper.arm();
return {
server,
socketPath,
connectionCount: () => connections,
close: shutdown,
};
}
finally {
// The critical section ends at bind(). From here on, a competing starter
// finds a socket that answers and backs off on its own.
lock.release();
}
}
function throwAddrInUse(message) {
const err = new Error(message);
err.code = 'EADDRINUSE';
throw err;
}