omnifocus-mcp
Version:
Model Context Protocol (MCP) server that integrates with OmniFocus for AI assistant interaction
165 lines (164 loc) • 8.62 kB
JavaScript
import { z } from 'zod';
import { batchAddItems } from '../primitives/batchAddItems.js';
import { repeatShape } from './repeatSchema.js';
export const schema = z.object({
items: z.array(z.object({
type: z.enum(['task', 'project']).describe("What to create"),
name: z.string().describe("Item name"),
note: z.string().optional().describe("Item note"),
dueDate: z.string().optional().describe("Due date (YYYY-MM-DD or full ISO)"),
deferDate: z.string().optional().describe("Defer date (YYYY-MM-DD or full ISO)"),
plannedDate: z.string().optional().describe("Planned date (ISO; tasks only)"),
flagged: z.boolean().optional().describe("Flag the item"),
estimatedMinutes: z.number().optional().describe("Time estimate in minutes"),
tags: z.array(z.string()).optional().describe("Tag names to assign"),
// Task-specific properties.
// Keep these in sync with add_omnifocus_task's schema — the two drifted
// apart once already (#97): projectId was missing here, so Zod stripped it
// and every task in the batch landed in the inbox reporting success.
projectId: z.string().optional().describe("Tasks: project id to place the task in; takes precedence over projectName. Prefer when names are ambiguous"),
projectName: z.string().optional().describe("Tasks: project name or folder path ('Work/My Project'); defaults to inbox"),
parentTaskId: z.string().optional().describe("Tasks: parent task id"),
parentTaskName: z.string().optional().describe("Tasks: parent task name, scoped to the project when one is given"),
tempId: z.string().optional().describe("Tasks: temporary id other items in this batch can reference"),
parentTempId: z.string().optional().describe("Tasks: nest under the batch item with this tempId"),
hierarchyLevel: z.number().int().min(0).optional().describe("Ordering hint (0 = root)"),
// Project-specific properties
folderName: z.string().optional().describe("Projects: folder to place the project in"),
sequential: z.boolean().optional().describe("Projects: make tasks sequential"),
repeat: repeatShape.optional()
})).describe("Items to add"),
createSequentially: z.boolean().optional().describe("Process parents before children; even when false, parents are resolved first best-effort")
});
/**
* Render where a task actually landed, next to where it was asked to go.
*
* The reason this exists (#97): the batch result used to be `✅ task: "name"`
* and nothing else, so a task that was supposed to go into a project but ended
* up in the inbox looked identical to one that worked. A silent placement
* fallthrough is only silent if nobody prints the placement.
*
* Returns '' for projects, which have no placement, and for results from a
* primitive that didn't report one.
*/
export function describePlacement(item, placement, batch = []) {
if (item.type !== 'task' || !placement)
return '';
// A parentTempId is meaningless to the caller on its own; resolve it back to
// the name of the sibling item it points at.
const sibling = item.parentTempId
? batch.find((other) => other.tempId === item.parentTempId)
: undefined;
const tempLabel = sibling?.name
? `"${sibling.name}"`
: item.parentTempId
? `tempId ${item.parentTempId}`
: undefined;
// Ids are rendered bare and names quoted, matching add_omnifocus_task — a
// quoted id reads as a name the caller can go look for and won't find.
const parentRequest = item.parentTaskId
? `id ${item.parentTaskId}`
: item.parentTaskName
? `"${item.parentTaskName}"`
: tempLabel;
const projectRequest = item.projectName
? `"${item.projectName}"`
: item.projectId
? `id ${item.projectId}`
: undefined;
if (placement === 'inbox') {
const requested = parentRequest ?? projectRequest;
return requested ? ` → ⚠️ inbox — requested ${requested}, not honored` : ' → inbox';
}
if (placement === 'parent') {
return parentRequest ? ` → under parent ${parentRequest}` : ' → under parent task';
}
// placement === 'project'
const label = projectRequest ?? tempLabel;
// Mirrors add_omnifocus_task: an explicitly named parent that didn't take is
// worth flagging even though the task did land somewhere reasonable.
const parentMissed = item.parentTaskId || item.parentTaskName
? ' ⚠️ parent not found'
: '';
return `${label ? ` → in project ${label}` : ' → in a project'}${parentMissed}`;
}
export async function handler(args, extra) {
try {
// Call the batchAddItems function
const result = await batchAddItems(args.items);
if (result.success) {
const successCount = result.results.filter(r => r.success).length;
const failureCount = result.results.filter(r => !r.success).length;
let message = `✅ Successfully added ${successCount} items.`;
if (failureCount > 0) {
message += ` ⚠️ Failed to add ${failureCount} items.`;
}
// Hoist silent misplacement into the summary line. Callers — agents
// especially — routinely read the first line and stop, which is how #97
// went unnoticed long enough to be reported as data loss.
const misplaced = result.results.filter((r, index) => {
if (!r.success || r.placement !== 'inbox')
return false;
const item = args.items[index];
return Boolean(item.projectId || item.projectName || item.parentTaskId || item.parentTaskName || item.parentTempId);
}).length;
if (misplaced > 0) {
message += ` ⚠️ ${misplaced} landed in the inbox despite a requested project or parent.`;
}
// Include details about added items
const details = result.results.map((item, index) => {
if (item.success) {
const itemType = args.items[index].type;
const itemName = args.items[index].name;
const where = describePlacement(args.items[index], item.placement, args.items);
// Echo each created id (#104): batch callers otherwise re-query for
// every id they need for follow-up edits.
const idText = item.id ? ` (id: ${item.id})` : '';
return `- ✅ ${itemType}: "${itemName}"${idText}${where}`;
}
else {
const itemType = args.items[index].type;
const itemName = args.items[index].name;
return `- ❌ ${itemType}: "${itemName}" - Error: ${item.error}`;
}
}).join('\n');
return {
content: [{
type: "text",
text: `${message}\n\n${details}`
}]
};
}
else {
console.error('[batch_add_items] failure result:', JSON.stringify(result));
// Batch operation failed completely or no items succeeded.
const failureDetails = (result.results && result.results.length > 0)
? result.results.map((r, index) => {
const itemType = args.items[index].type;
const itemName = args.items[index].name;
return r.success
? `- ✅ ${itemType}: \"${itemName}\"${r.id ? ` (id: ${r.id})` : ''}${describePlacement(args.items[index], r.placement, args.items)}`
: `- ❌ ${itemType}: \"${itemName}\" - Error: ${r?.error || 'Unknown error'}`;
}).join('\\n')
: `No items processed. ${result.error || ''}`;
return {
content: [{
type: "text",
text: `Failed to process batch operation.\\n\\n${failureDetails}`
}],
isError: true
};
}
}
catch (err) {
const error = err;
console.error(`Tool execution error: ${error.message}`);
return {
content: [{
type: "text",
text: `Error processing batch operation: ${error.message}`
}],
isError: true
};
}
}