omnifocus-mcp
Version:
Model Context Protocol (MCP) server that integrates with OmniFocus for AI assistant interaction
139 lines (138 loc) • 5.75 kB
JavaScript
/**
* Session state needed to survive a daemon restart (issue #123).
*
* The shim is a byte pipe, which is a complete implementation right up until the
* daemon dies underneath it. Then the shim exits, and the client — which treats a
* stdio server exit as terminal — loses the tools for the rest of its life. One
* agent session lost OmniFocus for a week that way and silently fell back to raw
* AppleScript, which is how the data-loss incident in #124 became reachable.
*
* Reconnecting the socket is easy. Resuming the *session* is not: MCP sessions
* are stateful, and the daemon builds a fresh `McpServer` per connection, so a
* new socket is a session that has never seen `initialize`. This tracks the
* minimum needed to rebuild that state on a new connection:
*
* - the `initialize` request and the `notifications/initialized` that follows
* - the ids of requests still awaiting a response
*
* The second matters as much as the first. A request whose response died with
* the old daemon never completes, and an MCP client will wait on it forever.
* Synthesising an error for each orphaned id turns an invisible hang into a
* normal, retryable failure.
*
* This only observes; it never rewrites the stream. The pipe stays byte-for-byte.
*
* LIMIT worth knowing before extending the daemon: replay is transparent only
* while a session carries no state beyond the handshake. Today the daemon's
* per-connection state is the initialize exchange, negotiated capabilities and a
* log level, all of which the replay restores. If anything stateful is added
* later — a subscription, a cursor, server-side pagination — replay would
* silently restore a *partial* session, and the failure would be subtle rather
* than loud. Extend this tracker at the same time, or make the shim exit instead.
*
* LIMIT on the orphan errors: they tell the caller to retry, which is right for
* reads and idempotent writes. A write killed mid-flight may have partially
* applied, so a retried batch create could duplicate — the same hazard #121
* addressed by never retrying writes. Re-query before retrying a write.
*/
export class SessionTracker {
/** Raw `initialize` line, replayed verbatim on a new connection. */
initializeLine = null;
/** Raw `notifications/initialized` line, if the client sent one. */
initializedLine = null;
pending = new Set();
buffer = '';
/** Observe bytes travelling client → daemon. */
observeOutbound(chunk) {
this.buffer += chunk;
let idx;
while ((idx = this.buffer.indexOf('\n')) !== -1) {
const line = this.buffer.slice(0, idx);
this.buffer = this.buffer.slice(idx + 1);
this.noteOutboundLine(line);
}
// Guard against a pathological line with no newline growing without bound.
if (this.buffer.length > 1_000_000)
this.buffer = '';
}
noteOutboundLine(line) {
const trimmed = line.trim();
if (!trimmed)
return;
let msg;
try {
msg = JSON.parse(trimmed);
}
catch {
return;
}
if (msg.method === 'initialize') {
this.initializeLine = trimmed;
}
else if (msg.method === 'notifications/initialized') {
this.initializedLine = trimmed;
}
// A message with an id and a method is a request awaiting a response.
if (msg.id !== undefined && msg.method !== undefined) {
this.pending.add(String(msg.id));
}
}
/** Observe bytes travelling daemon → client, to retire answered requests. */
observeInbound(chunk) {
for (const line of chunk.split('\n')) {
const trimmed = line.trim();
if (!trimmed)
continue;
try {
const msg = JSON.parse(trimmed);
if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
this.pending.delete(String(msg.id));
}
}
catch {
// Partial or non-JSON frame; a later chunk completes it. Leaving the id
// pending is the safe direction — a spurious error beats a silent hang.
}
}
}
/** True once the client has completed a handshake worth replaying. */
get canReplay() {
return this.initializeLine !== null;
}
/** Lines to send on a fresh connection, in order. */
replayLines() {
const lines = [];
if (this.initializeLine)
lines.push(this.initializeLine);
if (this.initializedLine)
lines.push(this.initializedLine);
return lines;
}
/**
* JSON-RPC error responses for requests orphaned by the disconnect.
*
* -32000 is the server-error range. The message names the cause, because a
* caller seeing this should retry rather than conclude the tool is broken.
*/
orphanedResponses() {
return [...this.pending].map(id => {
const numeric = /^-?\d+$/.test(id);
return JSON.stringify({
jsonrpc: '2.0',
id: numeric ? Number(id) : id,
error: {
code: -32000,
message: 'The OmniFocus MCP daemon restarted while this request was in flight. The session has been re-established; retry the request.',
},
});
});
}
/** Called after replay so the ids are not reported twice. */
clearPending() {
this.pending.clear();
}
/** Test/inspection helper. */
get pendingIds() {
return [...this.pending];
}
}