omnifocus-mcp
Version:
Model Context Protocol (MCP) server that integrates with OmniFocus for AI assistant interaction
284 lines (283 loc) • 15.4 kB
JavaScript
import { z } from 'zod';
import { queryOmnifocus } from '../primitives/queryOmnifocus.js';
import { resolveDateFilter } from '../../utils/dateFilter.js';
import { localDatePart } from '../../utils/dateSerialization.js';
// Description budget (#105): these strings load into EVERY session that touches
// the server, so shared grammar is stated once on the filters object and each
// field describe keeps only what an agent can't infer — case-sensitivity,
// exact-vs-partial matching, and cross-field requirements. A test caps the
// total; spend the budget deliberately.
export const schema = z.object({
entity: z.enum(['tasks', 'projects', 'folders']).describe("What to query"),
filters: z.object({
projectId: z.string().optional().describe("Exact project id"),
projectName: z.string().optional().describe("Project name; case-insensitive partial match. 'inbox' targets the inbox"),
taskName: z.string().optional().describe("Task name; case-insensitive partial match"),
folderId: z.string().optional().describe("Folder id, including subfolders; tasks match via their containing project"),
folderName: z.string().optional().describe("Folder name; case-insensitive partial match, may match several folders, each including subfolders. folderId takes precedence"),
tags: z.array(z.string()).optional().describe("Tag names; exact match, case-sensitive"),
status: z.array(z.string()).optional().describe("Tasks: Next, Available, Blocked, DueSoon, Overdue, Completed, Dropped. Projects: Active, OnHold, Done, Dropped"),
flagged: z.boolean().optional().describe("true = flagged only, false = unflagged only"),
dueWithin: z.union([z.number(), z.string()]).optional().describe("Due between today and the given day/range"),
deferredUntil: z.union([z.number(), z.string()]).optional().describe("Currently deferred, becoming available by the given day"),
plannedWithin: z.union([z.number(), z.string()]).optional().describe("Planned between today and the given day/range"),
hasNote: z.boolean().optional().describe("true = has a non-empty note"),
inbox: z.boolean().optional().describe("true = inbox tasks only, false = project tasks only"),
dueOn: z.union([z.number(), z.string()]).optional().describe("Due on exactly that day"),
deferOn: z.union([z.number(), z.string()]).optional().describe("Defer date exactly that day"),
plannedOn: z.union([z.number(), z.string()]).optional().describe("Planned date exactly that day"),
addedWithin: z.number().optional().describe("Added in the last N days"),
addedOn: z.number().optional().describe("Added on day N (0 = today, -1 = yesterday)"),
isRepeating: z.boolean().optional().describe("true = repeating tasks only"),
completedWithin: z.number().optional().describe("Completed in the last N days (dropped items need droppedWithin). Requires includeCompleted: true"),
completedOn: z.number().optional().describe("Completed on day N (0 = today, -1 = yesterday). Requires includeCompleted: true"),
droppedWithin: z.number().optional().describe("Dropped in the last N days. Requires includeCompleted: true"),
droppedOn: z.number().optional().describe("Dropped on day N (0 = today, -1 = yesterday). Requires includeCompleted: true"),
reviewDue: z.boolean().optional().describe("true = projects due for review (projects only)")
}).optional().describe("Filters AND together; array filters (tags, status) OR within the array. Date-valued filters (dueWithin, deferredUntil, plannedWithin, dueOn, deferOn, plannedOn) accept a number of days from today, 'today', 'tomorrow', 'this week', 'next week', or 'YYYY-MM-DD'"),
fields: z.array(z.string()).optional().describe("Only return the listed fields (smaller responses). Tasks: id, name, note, flagged, taskStatus, dueDate, deferDate, plannedDate, effectiveDueDate, effectiveDeferDate, effectivePlannedDate, completionDate, dropDate, effectiveDropDate, estimatedMinutes, tagNames, tags, projectName, projectId, parentId, childIds, hasChildren, sequential, completedByChildren, inInbox, isRepeating, repetitionRule (ICS, e.g. FREQ=WEEKLY;INTERVAL=2), repetitionMethod (Fixed | DeferUntilDate | DueDate), isPastOccurrence, modificationDate, creationDate. Projects: id, name, status, note, folderName, folderID, sequential, dueDate, deferDate, effectiveDueDate, effectiveDeferDate, completionDate, dropDate, effectiveDropDate, completedByChildren, containsSingletonActions, taskCount, tasks, nextReviewDate, reviewInterval, modificationDate, creationDate. Folders: id, name, path, parentFolderID, status, projectCount, projects, subfolders"),
limit: z.number().optional().describe("Max items to return"),
sortBy: z.string().optional().describe("name, dueDate, deferDate, modificationDate, creationDate, estimatedMinutes, or taskStatus"),
sortOrder: z.enum(['asc', 'desc']).optional().describe("Default: asc"),
includeCompleted: z.boolean().optional().describe("Include completed/dropped items (default: false)"),
summary: z.boolean().optional().describe("Return only the match count")
});
export async function handler(args, extra) {
try {
// Normalize date filter strings to numbers
const normalizedArgs = { ...args };
if (normalizedArgs.filters) {
const f = { ...normalizedArgs.filters };
const dateFields = ['dueWithin', 'deferredUntil', 'plannedWithin', 'dueOn', 'deferOn', 'plannedOn'];
for (const field of dateFields) {
if (f[field] !== undefined) {
f[field] = resolveDateFilter(f[field]);
}
}
normalizedArgs.filters = f;
}
// Call the queryOmniFocus function
const result = await queryOmnifocus(normalizedArgs);
if (result.success) {
// Format response based on whether it's a summary or full results
if (args.summary) {
return {
content: [{
type: "text",
text: `Found ${result.count} ${args.entity} matching your criteria.`
}]
};
}
else {
// Format the results in a compact, readable format
const items = result.items || [];
let output = formatQueryResults(items, args.entity);
// Add metadata about the query
if (items.length === args.limit) {
output += `\n\n⚠️ Results limited to ${args.limit} items. More may be available.`;
}
return {
content: [{
type: "text",
text: output
}]
};
}
}
else {
return {
content: [{
type: "text",
text: `Query failed: ${result.error}`
}],
isError: true
};
}
}
catch (err) {
const error = err;
console.error(`Query execution error: ${error.message}`);
return {
content: [{
type: "text",
text: `Error executing query: ${error.message}`
}],
isError: true
};
}
}
// Helper function to format query results in a compact way.
//
// Deliberately spare (#106): the caller is an agent that still has its own
// arguments in context, so restating them ("Filters applied: …", a markdown
// header) was measured at ~15% overhead on typical results and carried no
// information. The count line stays — it's the one thing the items themselves
// don't say.
function formatQueryResults(items, entity) {
if (items.length === 0) {
return `No ${entity} found matching the specified criteria.`;
}
let output = `${items.length} ${entity}:\n`;
// Format each item based on entity type
switch (entity) {
case 'tasks':
output += formatTasks(items);
break;
case 'projects':
output += formatProjects(items);
break;
case 'folders':
output += formatFolders(items);
break;
}
return output;
}
function formatTasks(tasks) {
return tasks.map(task => {
const parts = [];
// Core display
const flag = task.flagged ? '🚩 ' : '';
parts.push(`• ${flag}${task.name || 'Unnamed'}`);
// Add ID if present
if (task.id) {
parts.push(`[${task.id}]`);
}
// Project context
if (task.projectName) {
parts.push(`(${task.projectName})`);
}
// Dates
if (task.dueDate) {
parts.push(`[due: ${formatDate(task.dueDate)}]`);
}
if (task.deferDate) {
parts.push(`[defer: ${formatDate(task.deferDate)}]`);
}
if (task.plannedDate) {
parts.push(`[planned: ${formatDate(task.plannedDate)}]`);
}
// Time estimate
if (task.estimatedMinutes) {
const hours = task.estimatedMinutes >= 60
? `${Math.floor(task.estimatedMinutes / 60)}h`
: `${task.estimatedMinutes}m`;
parts.push(`(${hours})`);
}
// Tags
if (task.tagNames?.length > 0) {
parts.push(`<${task.tagNames.join(',')}>`);
}
// Status
if (task.taskStatus) {
parts.push(`#${task.taskStatus.toLowerCase()}`);
}
// A completed occurrence of a repeating item is not a duplicate (#124).
// Two rows, same name, different ids, read as duplication unless the history
// is labelled — which is how one agent "cleaned up" a live repeat chain.
if (task.isPastOccurrence) {
parts.push('⟲ past occurrence — not a duplicate');
}
// Repeating
if (task.isRepeating !== undefined) {
parts.push(task.isRepeating ? '[repeating]' : '[not repeating]');
}
// Repetition rule. Before #115 this rendered the literal string
// "[object Task.RepetitionRule]"; it now carries the ICS rule, and the
// method rides alongside it because "every 2 weeks" means something
// different depending on whether it counts from the calendar or from
// completion.
if (task.repetitionRule) {
const method = task.repetitionMethod ? ` ${task.repetitionMethod}` : '';
parts.push(`[rule: ${task.repetitionRule}${method}]`);
}
else if (task.repetitionMethod) {
parts.push(`[rule method: ${task.repetitionMethod}]`);
}
// Hierarchy info
if (task.parentId) {
parts.push(`[parent: ${task.parentId}]`);
}
if (task.hasChildren && task.childIds?.length > 0) {
parts.push(`[children: ${task.childIds.join(', ')}]`);
}
// Sequencing only matters for action groups (tasks with children).
if (task.sequential !== undefined && task.hasChildren) {
parts.push(task.sequential ? '[sequential]' : '[parallel]');
}
// Metadata dates if requested
if (task.creationDate) {
parts.push(`[created: ${formatDate(task.creationDate)}]`);
}
if (task.modificationDate) {
parts.push(`[modified: ${formatDate(task.modificationDate)}]`);
}
if (task.completionDate) {
parts.push(`[completed: ${formatDate(task.completionDate)}]`);
}
if (task.dropDate) {
parts.push(`[dropped: ${formatDate(task.dropDate)}]`);
}
let result = parts.join(' ');
// Add note on a new line if present
if (task.note) {
result += `\n Note: ${task.note}`;
}
return result;
}).join('\n');
}
function formatProjects(projects) {
return projects.map(project => {
// The truthiness guard matters (#106): when the caller's `fields` selection
// omits status, `undefined !== 'Active'` used to render a literal
// "[undefined]" on every row — 49 times in one live 49-project query.
const status = project.status && project.status !== 'Active' ? ` [${project.status}]` : '';
const folder = project.folderName ? ` 📁 ${project.folderName}` : '';
const taskCount = project.taskCount !== undefined && project.taskCount !== null ? ` (${project.taskCount} tasks)` : '';
const flagged = project.flagged ? '🚩 ' : '';
const due = project.dueDate ? ` [due: ${formatDate(project.dueDate)}]` : '';
const review = project.nextReviewDate ? ` [review: ${formatDate(project.nextReviewDate)}]` : '';
const reviewInterval = project.reviewInterval ? ` [review every: ${project.reviewInterval}]` : '';
const sequencing = project.sequential !== undefined
? (project.sequential ? ' [sequential]' : ' [parallel]')
: '';
const id = project.id ? ` [${project.id}]` : '';
const tags = project.tagNames?.length > 0 ? ` <${project.tagNames.join(',')}>` : '';
const pastOccurrence = project.isPastOccurrence ? ' ⟲ past occurrence — not a duplicate' : '';
let result = `P: ${flagged}${project.name}${id}${status}${due}${review}${reviewInterval}${sequencing}${folder}${taskCount}${tags}${pastOccurrence}`;
// Add note on a new line if present
if (project.note) {
result += `\n Note: ${project.note}`;
}
return result;
}).join('\n');
}
function formatFolders(folders) {
return folders.map(folder => {
const id = folder.id ? ` [${folder.id}]` : '';
const projectCount = folder.projectCount !== undefined ? ` (${folder.projectCount} projects)` : '';
// Only render the path once it says something the name doesn't. A top-level
// folder's path is just its own name, and "📍 Health" beside "F: Health"
// reads like a confirmed location when it is only an echo — which is part of
// how the flat-path bug (#95) stayed invisible.
const path = folder.path && folder.path !== folder.name ? ` 📍 ${folder.path}` : '';
// Fields formatFolders used to drop on the floor: requesting parentFolderID
// and seeing nothing rendered is indistinguishable from requesting it and
// getting null back (#95).
const parentFolderID = folder.parentFolderID ? ` ⬆️ ${folder.parentFolderID}` : '';
const subfolders = folder.subfolders?.length ? ` 📁 ${folder.subfolders.length} subfolders` : '';
return `F: ${folder.name}${id}${projectCount}${path}${parentFolderID}${subfolders}`;
}).join('\n');
}
function formatDate(dateStr) {
// Delegates to localDatePart rather than `new Date(x).toISOString().slice(0,10)`:
// that round-trip re-introduced the off-by-one day for UTC-ahead users even after
// the query layer started emitting correct local dates (#91).
return localDatePart(dateStr);
}
// Exported for testing only - not part of the public API
export const _testExports = {
formatTasks,
formatProjects,
formatFolders,
formatQueryResults,
};