UNPKG

omnifocus-mcp

Version:

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

138 lines (137 loc) 5.16 kB
import { readdirSync } from 'fs'; import { createConnection } from 'net'; import { join } from 'path'; import { SERVER_VERSION } from '../version.js'; import { resolveSocketDir } from './socketPath.js'; /** * In-band upgrade nudge (issue #113). * * #99 versioned the daemon socket so an upgrade can never silently serve old * code to new clients — but it left the inverse gap: clients attached to the * OLD daemon keep running it indefinitely, and nothing tells them a newer * version is available. The design principle here is that nobody should have * to remember anything: any tool call made while stale-attached carries one * appended line saying a newer daemon is serving and a reconnect upgrades. * * The signal is derived from the same mechanism #99 introduced: the socket * directory. A `daemon-<version>.sock` with a strictly newer semver, which is * actually accepting connections, means a newer server is live on this machine * right now. Liveness is probed so a crash-orphaned socket file cannot nag * forever, and the whole check is throttled so its cost amortizes to nothing. */ /** Only clean release versions participate; prereleases and odd slugs don't. */ const SOCKET_VERSION_RE = /^daemon-(\d+\.\d+\.\d+)\.sock$/; export function parseSocketVersion(filename) { const match = SOCKET_VERSION_RE.exec(filename); return match ? match[1] : null; } /** Standard numeric semver comparison for the major.minor.patch subset. */ export function compareVersions(a, b) { const pa = a.split('.').map(Number); const pb = b.split('.').map(Number); for (let i = 0; i < 3; i++) { if (pa[i] !== pb[i]) return pa[i] - pb[i]; } return 0; } export function upgradeNoticeText(current, newer) { return `⬆️ omnifocus-mcp ${newer} is serving on this machine; this connection is on ${current}. Reconnect the MCP server to upgrade.`; } /** True if something is actually accepting connections on the socket. */ function probeSocketAlive(socketPath, timeoutMs = 250) { return new Promise(resolve => { const socket = createConnection(socketPath); let settled = false; const done = (alive) => { if (settled) return; settled = true; socket.destroy(); resolve(alive); }; socket.once('connect', () => done(true)); socket.once('error', () => done(false)); socket.setTimeout(timeoutMs, () => done(false)); }); } /** * Find the newest strictly-newer version with a live daemon socket, or null. * Dependency-injected for tests; the real wiring comes from the defaults. */ export async function findNewerServingVersion(deps = {}) { const current = deps.currentVersion ?? SERVER_VERSION; const dir = deps.socketDir ?? resolveSocketDir(); const listDir = deps.listDir ?? readdirSync; const probe = deps.probe ?? probeSocketAlive; let entries; try { entries = listDir(dir); } catch { return null; } const newer = entries .map(name => ({ name, version: parseSocketVersion(name) })) .filter((e) => e.version !== null) .filter(e => compareVersions(e.version, current) > 0) .sort((a, b) => compareVersions(b.version, a.version)); for (const candidate of newer) { if (await probe(join(dir, candidate.name))) { return candidate.version; } } return null; } const CHECK_TTL_MS = 5 * 60_000; let cache = null; /** Test hook: reset the throttle cache. */ export function _resetNudgeCache() { cache = null; } /** * The throttled, production entry point: the current notice text, or null. * * Skipped entirely under OMNIFOCUS_MCP_SOCKET — an isolated instance's socket * lives outside the shared directory, so the shared directory says nothing * about it. */ export async function upgradeNotice() { if (process.env.OMNIFOCUS_MCP_SOCKET) return null; const now = Date.now(); if (cache && now - cache.at < CHECK_TTL_MS) return cache.notice; let notice = null; try { const newer = await findNewerServingVersion(); if (newer) notice = upgradeNoticeText(SERVER_VERSION, newer); } catch { // The nudge is best-effort; never let it break a tool result. } cache = { at: now, notice }; return notice; } /** * Wrap a tool handler so its result carries the upgrade notice while (and only * while) a newer daemon is serving. Appended as its own content block; error * results are nudged too — a stale client debugging an error is exactly the * client that should hear "reconnect". */ export function withUpgradeNudge(handler, notice = upgradeNotice) { return async (...args) => { const result = await handler(...args); try { const text = await notice(); if (text && result && Array.isArray(result.content)) { result.content.push({ type: 'text', text }); } } catch { // Never let the nudge interfere with the actual result. } return result; }; }