mcp-omnifocus 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 mcp-omnifocus contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # MCP OmniFocus
2
+
3
+ MCP server for OmniFocus with auto-detection of Pro/Standard version.
4
+
5
+ ## Features
6
+
7
+ - **Auto-detection**: Automatically detects OmniFocus Pro or Standard
8
+ - **Full Pro support**: AppleScript for read/write with sync
9
+ - **Standard fallback**: SQLite read + URL scheme for create
10
+
11
+ ### Capabilities by Version
12
+
13
+ | Feature | Pro (AppleScript) | Standard |
14
+ |---------|-------------------|----------|
15
+ | Read tasks | ✓ | ✓ (SQLite) |
16
+ | Create task | ✓ | ✓ (URL scheme, syncs) |
17
+ | Update task | ✓ | ⚠️ (SQLite, no sync) |
18
+ | Complete task | ✓ | ⚠️ (SQLite, no sync) |
19
+ | Get projects | ✓ | ✓ (SQLite) |
20
+
21
+ **⚠️ Standard SQLite write**: Changes don't sync until OmniFocus restart.
22
+
23
+ ## Installation
24
+
25
+ ```bash
26
+ cd ~/Projects/mcp-omnifocus
27
+ npm install
28
+ npm run build
29
+ ```
30
+
31
+ ## Claude Code Configuration
32
+
33
+ Add to `~/.claude/claude_desktop_config.json`:
34
+
35
+ ```json
36
+ {
37
+ "mcpServers": {
38
+ "omnifocus": {
39
+ "command": "node",
40
+ "args": ["/Users/YOUR_USERNAME/Projects/mcp-omnifocus/dist/index.js"]
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ ## Tools
47
+
48
+ ### omnifocus_get_tasks
49
+ Get tasks filtered by flagged, due today, or all.
50
+
51
+ ### omnifocus_create_task
52
+ Create a new task with name, note, project, flagged, dueDate.
53
+
54
+ ### omnifocus_update_task
55
+ Update existing task (Pro: syncs, Standard: SQLite only).
56
+
57
+ ### omnifocus_complete_task
58
+ Mark task as complete (Pro: syncs, Standard: SQLite only).
59
+
60
+ ### omnifocus_get_projects
61
+ Get list of active projects.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
5
+ import { createRequire } from "module";
6
+ import { ZodError } from "zod";
7
+ const require = createRequire(import.meta.url);
8
+ const { version: PACKAGE_VERSION } = require("../package.json");
9
+ import { detectVersion } from "./version-detector.js";
10
+ import { AppleScriptProvider } from "./providers/applescript.js";
11
+ import { UrlSchemeProvider } from "./providers/url-scheme.js";
12
+ import { CreateTaskInputSchema, UpdateTaskInputSchema, CompleteTaskInputSchema, GetTasksInputSchema, SetConfigInputSchema, } from "./validation.js";
13
+ function sanitizeErrorMessage(error) {
14
+ if (error instanceof ZodError) {
15
+ return error.errors.map(e => `${e.path.join(".")}: ${e.message}`).join("; ");
16
+ }
17
+ if (error instanceof Error) {
18
+ const msg = error.message;
19
+ // remove file paths and sensitive info
20
+ return msg
21
+ .replace(/\/Users\/[^/\s]+/g, "/Users/***")
22
+ .replace(/\/home\/[^/\s]+/g, "/home/***")
23
+ .replace(/at\s+.+:\d+:\d+/g, "")
24
+ .trim();
25
+ }
26
+ return "An unexpected error occurred";
27
+ }
28
+ let provider;
29
+ const server = new Server({
30
+ name: "mcp-omnifocus",
31
+ version: PACKAGE_VERSION,
32
+ }, {
33
+ capabilities: {
34
+ tools: {},
35
+ },
36
+ });
37
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
38
+ const version = provider.version;
39
+ const writeWarning = version === "standard"
40
+ ? " (Standard version: changes via SQLite won't sync until OmniFocus restart)"
41
+ : "";
42
+ return {
43
+ tools: [
44
+ {
45
+ name: "omnifocus_get_tasks",
46
+ description: `Get tasks from OmniFocus. Filter by flagged, due today, or all. Detected version: ${version}`,
47
+ inputSchema: {
48
+ type: "object",
49
+ properties: {
50
+ filter: {
51
+ type: "string",
52
+ enum: ["flagged", "due_today", "all"],
53
+ description: "Filter tasks: flagged, due_today, or all (default: flagged + due today)"
54
+ }
55
+ }
56
+ }
57
+ },
58
+ {
59
+ name: "omnifocus_create_task",
60
+ description: `Create a new task in OmniFocus. Detected version: ${version}`,
61
+ inputSchema: {
62
+ type: "object",
63
+ properties: {
64
+ name: {
65
+ type: "string",
66
+ description: "Task name (required)"
67
+ },
68
+ note: {
69
+ type: "string",
70
+ description: "Task note/description"
71
+ },
72
+ project: {
73
+ type: "string",
74
+ description: "Project name to add task to (optional, defaults to inbox)"
75
+ },
76
+ flagged: {
77
+ type: "boolean",
78
+ description: "Mark task as flagged"
79
+ },
80
+ dueDate: {
81
+ type: "string",
82
+ description: "Due date in ISO format (YYYY-MM-DD)"
83
+ }
84
+ },
85
+ required: ["name"]
86
+ }
87
+ },
88
+ {
89
+ name: "omnifocus_update_task",
90
+ description: `Update an existing task in OmniFocus.${writeWarning}`,
91
+ inputSchema: {
92
+ type: "object",
93
+ properties: {
94
+ taskId: {
95
+ type: "string",
96
+ description: "Task ID (required)"
97
+ },
98
+ name: {
99
+ type: "string",
100
+ description: "New task name"
101
+ },
102
+ note: {
103
+ type: "string",
104
+ description: "New task note"
105
+ },
106
+ flagged: {
107
+ type: "boolean",
108
+ description: "Set flagged status"
109
+ },
110
+ dueDate: {
111
+ type: "string",
112
+ description: "New due date in ISO format"
113
+ }
114
+ },
115
+ required: ["taskId"]
116
+ }
117
+ },
118
+ {
119
+ name: "omnifocus_complete_task",
120
+ description: `Mark a task as complete.${writeWarning}`,
121
+ inputSchema: {
122
+ type: "object",
123
+ properties: {
124
+ taskId: {
125
+ type: "string",
126
+ description: "Task ID to complete (required)"
127
+ }
128
+ },
129
+ required: ["taskId"]
130
+ }
131
+ },
132
+ {
133
+ name: "omnifocus_get_projects",
134
+ description: `Get list of active projects from OmniFocus. Detected version: ${version}`,
135
+ inputSchema: {
136
+ type: "object",
137
+ properties: {}
138
+ }
139
+ },
140
+ {
141
+ name: "omnifocus_get_config",
142
+ description: "Get current configuration settings",
143
+ inputSchema: {
144
+ type: "object",
145
+ properties: {}
146
+ }
147
+ },
148
+ {
149
+ name: "omnifocus_set_config",
150
+ description: "Update configuration settings. For Standard version: directSqlAccess controls whether to use direct SQLite access for update/complete operations (faster but requires OmniFocus restart to sync)",
151
+ inputSchema: {
152
+ type: "object",
153
+ properties: {
154
+ directSqlAccess: {
155
+ type: "boolean",
156
+ description: "Enable direct SQLite access for write operations (Standard version only)"
157
+ },
158
+ taskLimit: {
159
+ type: "number",
160
+ description: "Maximum number of tasks to return from getTasks (default: 500, max: 10000)"
161
+ }
162
+ }
163
+ }
164
+ }
165
+ ]
166
+ };
167
+ });
168
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
169
+ const { name, arguments: args } = request.params;
170
+ try {
171
+ switch (name) {
172
+ case "omnifocus_get_tasks": {
173
+ const input = GetTasksInputSchema.parse(args ?? {});
174
+ const tasks = await provider.getTasks(input.filter);
175
+ return {
176
+ content: [{
177
+ type: "text",
178
+ text: JSON.stringify({ version: provider.version, tasks }, null, 2)
179
+ }]
180
+ };
181
+ }
182
+ case "omnifocus_create_task": {
183
+ const input = CreateTaskInputSchema.parse(args ?? {});
184
+ const result = await provider.createTask(input);
185
+ return {
186
+ content: [{
187
+ type: "text",
188
+ text: JSON.stringify({ version: provider.version, ...result }, null, 2)
189
+ }]
190
+ };
191
+ }
192
+ case "omnifocus_update_task": {
193
+ const input = UpdateTaskInputSchema.parse(args ?? {});
194
+ const result = await provider.updateTask(input);
195
+ return {
196
+ content: [{
197
+ type: "text",
198
+ text: JSON.stringify({ version: provider.version, ...result }, null, 2)
199
+ }]
200
+ };
201
+ }
202
+ case "omnifocus_complete_task": {
203
+ const input = CompleteTaskInputSchema.parse(args ?? {});
204
+ const result = await provider.completeTask(input.taskId);
205
+ return {
206
+ content: [{
207
+ type: "text",
208
+ text: JSON.stringify({ version: provider.version, ...result }, null, 2)
209
+ }]
210
+ };
211
+ }
212
+ case "omnifocus_get_projects": {
213
+ const projects = await provider.getProjects();
214
+ return {
215
+ content: [{
216
+ type: "text",
217
+ text: JSON.stringify({ version: provider.version, projects }, null, 2)
218
+ }]
219
+ };
220
+ }
221
+ case "omnifocus_get_config": {
222
+ return {
223
+ content: [{
224
+ type: "text",
225
+ text: JSON.stringify({
226
+ version: provider.version,
227
+ config: provider.config
228
+ }, null, 2)
229
+ }]
230
+ };
231
+ }
232
+ case "omnifocus_set_config": {
233
+ const input = SetConfigInputSchema.parse(args ?? {});
234
+ provider.setConfig(input);
235
+ return {
236
+ content: [{
237
+ type: "text",
238
+ text: JSON.stringify({
239
+ success: true,
240
+ version: provider.version,
241
+ config: provider.config
242
+ }, null, 2)
243
+ }]
244
+ };
245
+ }
246
+ default:
247
+ throw new Error(`Unknown tool: ${name}`);
248
+ }
249
+ }
250
+ catch (error) {
251
+ console.error("[mcp-omnifocus] Error:", error);
252
+ const message = sanitizeErrorMessage(error);
253
+ return {
254
+ content: [{
255
+ type: "text",
256
+ text: JSON.stringify({ error: message }, null, 2)
257
+ }],
258
+ isError: true
259
+ };
260
+ }
261
+ });
262
+ async function main() {
263
+ console.error("[mcp-omnifocus] Starting...");
264
+ const version = await detectVersion();
265
+ console.error(`[mcp-omnifocus] Detected OmniFocus version: ${version}`);
266
+ provider = version === "pro"
267
+ ? new AppleScriptProvider()
268
+ : new UrlSchemeProvider();
269
+ const transport = new StdioServerTransport();
270
+ await server.connect(transport);
271
+ console.error("[mcp-omnifocus] Server connected");
272
+ }
273
+ main().catch((error) => {
274
+ console.error("[mcp-omnifocus] Fatal error:", error);
275
+ process.exit(1);
276
+ });
@@ -0,0 +1,19 @@
1
+ import type { OmniFocusProvider, OmniFocusTask, CreateTaskInput, UpdateTaskInput, OmniFocusConfig } from "../types.js";
2
+ export declare function escapeAppleScriptString(str: string): string;
3
+ export declare class AppleScriptProvider implements OmniFocusProvider {
4
+ version: "pro";
5
+ config: OmniFocusConfig;
6
+ setConfig(newConfig: Partial<OmniFocusConfig>): void;
7
+ getTasks(filter?: "flagged" | "due_today" | "all"): Promise<OmniFocusTask[]>;
8
+ createTask(input: CreateTaskInput): Promise<{
9
+ success: boolean;
10
+ taskId?: string;
11
+ }>;
12
+ updateTask(input: UpdateTaskInput): Promise<{
13
+ success: boolean;
14
+ }>;
15
+ completeTask(taskId: string): Promise<{
16
+ success: boolean;
17
+ }>;
18
+ getProjects(): Promise<string[]>;
19
+ }
@@ -0,0 +1,204 @@
1
+ import { spawn } from "child_process";
2
+ import { DEFAULT_TASK_LIMIT } from "../types.js";
3
+ async function runAppleScript(script) {
4
+ return new Promise((resolve, reject) => {
5
+ const child = spawn("osascript", ["-"], {
6
+ stdio: ["pipe", "pipe", "pipe"],
7
+ });
8
+ let stdout = "";
9
+ let stderr = "";
10
+ child.stdout.on("data", (data) => {
11
+ stdout += data.toString();
12
+ });
13
+ child.stderr.on("data", (data) => {
14
+ stderr += data.toString();
15
+ });
16
+ child.on("close", (code) => {
17
+ if (code === 0) {
18
+ resolve(stdout.trim());
19
+ }
20
+ else {
21
+ reject(new Error(`AppleScript failed: ${stderr.trim() || "Unknown error"}`));
22
+ }
23
+ });
24
+ child.on("error", (err) => {
25
+ reject(err);
26
+ });
27
+ child.stdin.write(script);
28
+ child.stdin.end();
29
+ });
30
+ }
31
+ export function escapeAppleScriptString(str) {
32
+ return str
33
+ .replace(/\\/g, "\\\\")
34
+ .replace(/"/g, '\\"')
35
+ .replace(/\r/g, "\\r")
36
+ .replace(/\n/g, "\\n")
37
+ .replace(/\t/g, "\\t");
38
+ }
39
+ export class AppleScriptProvider {
40
+ version = "pro";
41
+ config = { directSqlAccess: false, taskLimit: DEFAULT_TASK_LIMIT };
42
+ setConfig(newConfig) {
43
+ // AppleScript provider doesn't use SQL, config is ignored
44
+ Object.assign(this.config, newConfig);
45
+ }
46
+ async getTasks(filter) {
47
+ let condition = "";
48
+ if (filter === "flagged") {
49
+ condition = "whose flagged is true";
50
+ }
51
+ else if (filter === "due_today") {
52
+ condition = "whose due date is not missing value and due date < (current date) + 1 * days";
53
+ }
54
+ const script = `
55
+ tell application "OmniFocus"
56
+ tell default document
57
+ set taskList to {}
58
+ set theTasks to flattened tasks ${condition}
59
+ repeat with t in theTasks
60
+ if completed of t is false then
61
+ set taskId to id of t
62
+ set taskName to name of t
63
+ set taskNote to note of t
64
+ set taskFlagged to flagged of t
65
+ set taskDue to ""
66
+ if due date of t is not missing value then
67
+ set taskDue to (due date of t) as «class isot» as string
68
+ end if
69
+ set projectName to ""
70
+ try
71
+ set projectName to name of containing project of t
72
+ end try
73
+ set end of taskList to taskId & "|||" & taskName & "|||" & taskNote & "|||" & taskFlagged & "|||" & taskDue & "|||" & projectName
74
+ end if
75
+ end repeat
76
+ set AppleScript's text item delimiters to "
77
+ ---TASK---
78
+ "
79
+ return taskList as text
80
+ end tell
81
+ end tell
82
+ `;
83
+ const result = await runAppleScript(script);
84
+ if (!result)
85
+ return [];
86
+ // use newline-based delimiter to handle commas in task names
87
+ return result.split("\n---TASK---\n").map(line => {
88
+ const [id, name, note, flagged, dueDate, project] = line.split("|||");
89
+ return {
90
+ id,
91
+ name,
92
+ note: note || undefined,
93
+ flagged: flagged === "true",
94
+ dueDate: dueDate || undefined,
95
+ project: project || undefined,
96
+ completed: false
97
+ };
98
+ });
99
+ }
100
+ async createTask(input) {
101
+ const escapedName = escapeAppleScriptString(input.name);
102
+ const props = [`name:"${escapedName}"`];
103
+ if (input.note) {
104
+ const escapedNote = escapeAppleScriptString(input.note);
105
+ props.push(`note:"${escapedNote}"`);
106
+ }
107
+ if (input.flagged) {
108
+ props.push("flagged:true");
109
+ }
110
+ if (input.dueDate) {
111
+ const escapedDate = escapeAppleScriptString(input.dueDate);
112
+ props.push(`due date:date "${escapedDate}"`);
113
+ }
114
+ let script;
115
+ if (input.project) {
116
+ const escapedProject = escapeAppleScriptString(input.project);
117
+ script = `
118
+ tell application "OmniFocus"
119
+ tell default document
120
+ set theProject to first flattened project whose name is "${escapedProject}"
121
+ set newTask to make new task with properties {${props.join(", ")}} at end of tasks of theProject
122
+ return id of newTask
123
+ end tell
124
+ end tell
125
+ `;
126
+ }
127
+ else {
128
+ script = `
129
+ tell application "OmniFocus"
130
+ tell default document
131
+ set newTask to make new inbox task with properties {${props.join(", ")}}
132
+ return id of newTask
133
+ end tell
134
+ end tell
135
+ `;
136
+ }
137
+ const taskId = await runAppleScript(script);
138
+ return { success: true, taskId };
139
+ }
140
+ async updateTask(input) {
141
+ const updates = [];
142
+ const escapedTaskId = escapeAppleScriptString(input.taskId);
143
+ if (input.name) {
144
+ const escapedName = escapeAppleScriptString(input.name);
145
+ updates.push(`set name of theTask to "${escapedName}"`);
146
+ }
147
+ if (input.note !== undefined) {
148
+ const escapedNote = escapeAppleScriptString(input.note);
149
+ updates.push(`set note of theTask to "${escapedNote}"`);
150
+ }
151
+ if (input.flagged !== undefined) {
152
+ updates.push(`set flagged of theTask to ${input.flagged}`);
153
+ }
154
+ if (input.dueDate) {
155
+ const escapedDate = escapeAppleScriptString(input.dueDate);
156
+ updates.push(`set due date of theTask to date "${escapedDate}"`);
157
+ }
158
+ const script = `
159
+ tell application "OmniFocus"
160
+ tell default document
161
+ set theTask to first flattened task whose id is "${escapedTaskId}"
162
+ ${updates.join("\n ")}
163
+ end tell
164
+ end tell
165
+ `;
166
+ await runAppleScript(script);
167
+ return { success: true };
168
+ }
169
+ async completeTask(taskId) {
170
+ const escapedTaskId = escapeAppleScriptString(taskId);
171
+ const script = `
172
+ tell application "OmniFocus"
173
+ tell default document
174
+ set theTask to first flattened task whose id is "${escapedTaskId}"
175
+ set completed of theTask to true
176
+ end tell
177
+ end tell
178
+ `;
179
+ await runAppleScript(script);
180
+ return { success: true };
181
+ }
182
+ async getProjects() {
183
+ const script = `
184
+ tell application "OmniFocus"
185
+ tell default document
186
+ set projectNames to {}
187
+ repeat with p in flattened projects
188
+ if status of p is active then
189
+ set end of projectNames to name of p
190
+ end if
191
+ end repeat
192
+ set AppleScript's text item delimiters to "
193
+ ---PROJECT---
194
+ "
195
+ return projectNames as text
196
+ end tell
197
+ end tell
198
+ `;
199
+ const result = await runAppleScript(script);
200
+ if (!result)
201
+ return [];
202
+ return result.split("\n---PROJECT---\n");
203
+ }
204
+ }
@@ -0,0 +1,20 @@
1
+ import type { OmniFocusProvider, OmniFocusTask, CreateTaskInput, UpdateTaskInput, OmniFocusConfig } from "../types.js";
2
+ export declare class UrlSchemeProvider implements OmniFocusProvider {
3
+ version: "standard";
4
+ config: OmniFocusConfig;
5
+ setConfig(newConfig: Partial<OmniFocusConfig>): void;
6
+ getTasks(filter?: "flagged" | "due_today" | "all"): Promise<OmniFocusTask[]>;
7
+ createTask(input: CreateTaskInput): Promise<{
8
+ success: boolean;
9
+ warning?: string;
10
+ }>;
11
+ updateTask(input: UpdateTaskInput): Promise<{
12
+ success: boolean;
13
+ warning?: string;
14
+ }>;
15
+ completeTask(taskId: string): Promise<{
16
+ success: boolean;
17
+ warning?: string;
18
+ }>;
19
+ getProjects(): Promise<string[]>;
20
+ }
@@ -0,0 +1,178 @@
1
+ import { execFile } from "child_process";
2
+ import { promisify } from "util";
3
+ import { homedir } from "os";
4
+ import Database from "better-sqlite3";
5
+ import { DEFAULT_TASK_LIMIT } from "../types.js";
6
+ const execFileAsync = promisify(execFile);
7
+ const DB_PATH = `${homedir()}/Library/Group Containers/34YW5XSRB7.com.omnigroup.OmniFocus/com.omnigroup.OmniFocus4/com.omnigroup.OmniFocusModel/OmniFocusDatabase.db`;
8
+ // Core Data (Apple) uses 2001-01-01 as epoch, Unix uses 1970-01-01
9
+ // difference is ~31 years (978307200 seconds)
10
+ const CORE_DATA_EPOCH_MS = new Date("2001-01-01T00:00:00Z").getTime();
11
+ function getDatabase(readonly = true) {
12
+ return new Database(DB_PATH, { readonly, fileMustExist: true });
13
+ }
14
+ export class UrlSchemeProvider {
15
+ version = "standard";
16
+ config = { directSqlAccess: true, taskLimit: DEFAULT_TASK_LIMIT };
17
+ setConfig(newConfig) {
18
+ Object.assign(this.config, newConfig);
19
+ }
20
+ async getTasks(filter) {
21
+ const db = getDatabase(true);
22
+ try {
23
+ let whereClause = "t.dateCompleted IS NULL";
24
+ if (filter === "flagged") {
25
+ whereClause += " AND t.flagged = 1";
26
+ }
27
+ else if (filter === "due_today") {
28
+ // '+31 years' converts Core Data epoch (2001) to Unix epoch (1970)
29
+ whereClause += " AND date(t.dateDue, 'unixepoch', '+31 years') = date('now')";
30
+ }
31
+ else if (!filter || filter === "all") {
32
+ whereClause += " AND (t.flagged = 1 OR date(t.dateDue, 'unixepoch', '+31 years') <= date('now'))";
33
+ }
34
+ const query = `
35
+ SELECT
36
+ t.persistentIdentifier as id,
37
+ t.name,
38
+ t.plainTextNote as note,
39
+ t.flagged,
40
+ datetime(t.dateDue, 'unixepoch', '+31 years') as dueDate,
41
+ p.name as project
42
+ FROM Task t
43
+ LEFT JOIN ProjectInfo pi ON t.containingProjectInfo = pi.pk
44
+ LEFT JOIN Task p ON pi.task = p.persistentIdentifier
45
+ WHERE ${whereClause}
46
+ ORDER BY t.dateDue ASC, t.flagged DESC
47
+ LIMIT ${this.config.taskLimit}
48
+ `;
49
+ const rows = db.prepare(query).all();
50
+ return rows.map(row => ({
51
+ id: row.id,
52
+ name: row.name,
53
+ note: row.note || undefined,
54
+ flagged: row.flagged === 1,
55
+ dueDate: row.dueDate || undefined,
56
+ project: row.project || undefined,
57
+ completed: false
58
+ }));
59
+ }
60
+ finally {
61
+ db.close();
62
+ }
63
+ }
64
+ async createTask(input) {
65
+ const params = new URLSearchParams();
66
+ params.set("name", input.name);
67
+ params.set("autosave", "true");
68
+ if (input.note) {
69
+ params.set("note", input.note);
70
+ }
71
+ if (input.flagged) {
72
+ params.set("flag", "true");
73
+ }
74
+ if (input.dueDate) {
75
+ params.set("due", input.dueDate);
76
+ }
77
+ if (input.project) {
78
+ params.set("project", input.project);
79
+ }
80
+ const url = `omnifocus:///add?${params.toString()}`;
81
+ await execFileAsync("open", [url]);
82
+ return {
83
+ success: true,
84
+ warning: "Task created via URL scheme. It will sync automatically."
85
+ };
86
+ }
87
+ async updateTask(input) {
88
+ if (!this.config.directSqlAccess) {
89
+ return {
90
+ success: false,
91
+ warning: "Direct SQL access is disabled. Update operations require directSqlAccess=true or OmniFocus Pro. Use omnifocus_set_config to enable it."
92
+ };
93
+ }
94
+ const db = getDatabase(false);
95
+ try {
96
+ const updates = [];
97
+ const params = {
98
+ taskId: input.taskId
99
+ };
100
+ if (input.name !== undefined) {
101
+ updates.push("name = @name");
102
+ params.name = input.name;
103
+ }
104
+ if (input.note !== undefined) {
105
+ updates.push("plainTextNote = @note");
106
+ params.note = input.note;
107
+ }
108
+ if (input.flagged !== undefined) {
109
+ updates.push("flagged = @flagged");
110
+ params.flagged = input.flagged ? 1 : 0;
111
+ }
112
+ if (input.dueDate !== undefined) {
113
+ const date = new Date(input.dueDate);
114
+ const timestamp = (date.getTime() - CORE_DATA_EPOCH_MS) / 1000;
115
+ updates.push("dateDue = @dateDue");
116
+ params.dateDue = timestamp;
117
+ }
118
+ if (updates.length === 0) {
119
+ return { success: true };
120
+ }
121
+ const query = `UPDATE Task SET ${updates.join(", ")} WHERE persistentIdentifier = @taskId`;
122
+ const stmt = db.prepare(query);
123
+ const result = stmt.run(params);
124
+ if (result.changes === 0) {
125
+ throw new Error("Task not found");
126
+ }
127
+ return {
128
+ success: true,
129
+ warning: "Task updated via SQLite. Changes won't sync until OmniFocus is restarted. Consider using OmniFocus Pro for full sync support."
130
+ };
131
+ }
132
+ finally {
133
+ db.close();
134
+ }
135
+ }
136
+ async completeTask(taskId) {
137
+ if (!this.config.directSqlAccess) {
138
+ return {
139
+ success: false,
140
+ warning: "Direct SQL access is disabled. Complete operations require directSqlAccess=true or OmniFocus Pro. Use omnifocus_set_config to enable it."
141
+ };
142
+ }
143
+ const db = getDatabase(false);
144
+ try {
145
+ const now = new Date();
146
+ const timestamp = (now.getTime() - CORE_DATA_EPOCH_MS) / 1000;
147
+ const stmt = db.prepare("UPDATE Task SET dateCompleted = @timestamp WHERE persistentIdentifier = @taskId");
148
+ const result = stmt.run({ timestamp, taskId });
149
+ if (result.changes === 0) {
150
+ throw new Error("Task not found");
151
+ }
152
+ return {
153
+ success: true,
154
+ warning: "Task marked complete via SQLite. Changes won't sync until OmniFocus is restarted. Consider using OmniFocus Pro for full sync support."
155
+ };
156
+ }
157
+ finally {
158
+ db.close();
159
+ }
160
+ }
161
+ async getProjects() {
162
+ const db = getDatabase(true);
163
+ try {
164
+ const query = `
165
+ SELECT t.name
166
+ FROM Task t
167
+ JOIN ProjectInfo pi ON t.persistentIdentifier = pi.task
168
+ WHERE t.dateCompleted IS NULL
169
+ ORDER BY t.name
170
+ `;
171
+ const rows = db.prepare(query).all();
172
+ return rows.map(row => row.name);
173
+ }
174
+ finally {
175
+ db.close();
176
+ }
177
+ }
178
+ }
@@ -0,0 +1,55 @@
1
+ export interface OmniFocusTask {
2
+ id: string;
3
+ name: string;
4
+ note?: string;
5
+ project?: string;
6
+ flagged?: boolean;
7
+ dueDate?: string;
8
+ completed?: boolean;
9
+ }
10
+ export interface CreateTaskInput {
11
+ name: string;
12
+ note?: string;
13
+ project?: string;
14
+ flagged?: boolean;
15
+ dueDate?: string;
16
+ }
17
+ export interface UpdateTaskInput {
18
+ taskId: string;
19
+ name?: string;
20
+ note?: string;
21
+ flagged?: boolean;
22
+ dueDate?: string;
23
+ }
24
+ export interface GetTasksInput {
25
+ filter?: "flagged" | "due_today" | "all";
26
+ }
27
+ export interface CompleteTaskInput {
28
+ taskId: string;
29
+ }
30
+ export type OmniFocusVersion = "pro" | "standard";
31
+ export interface OmniFocusConfig {
32
+ directSqlAccess: boolean;
33
+ taskLimit: number;
34
+ }
35
+ export declare const DEFAULT_TASK_LIMIT = 500;
36
+ export interface OmniFocusProvider {
37
+ config: OmniFocusConfig;
38
+ setConfig(config: Partial<OmniFocusConfig>): void;
39
+ version: OmniFocusVersion;
40
+ getTasks(filter?: "flagged" | "due_today" | "all"): Promise<OmniFocusTask[]>;
41
+ createTask(input: CreateTaskInput): Promise<{
42
+ success: boolean;
43
+ taskId?: string;
44
+ warning?: string;
45
+ }>;
46
+ updateTask(input: UpdateTaskInput): Promise<{
47
+ success: boolean;
48
+ warning?: string;
49
+ }>;
50
+ completeTask(taskId: string): Promise<{
51
+ success: boolean;
52
+ warning?: string;
53
+ }>;
54
+ getProjects(): Promise<string[]>;
55
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export const DEFAULT_TASK_LIMIT = 500;
@@ -0,0 +1,68 @@
1
+ import { z } from "zod";
2
+ export declare const CreateTaskInputSchema: z.ZodObject<{
3
+ name: z.ZodString;
4
+ note: z.ZodOptional<z.ZodString>;
5
+ project: z.ZodOptional<z.ZodString>;
6
+ flagged: z.ZodOptional<z.ZodBoolean>;
7
+ dueDate: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
8
+ }, "strip", z.ZodTypeAny, {
9
+ name: string;
10
+ flagged?: boolean | undefined;
11
+ note?: string | undefined;
12
+ dueDate?: string | undefined;
13
+ project?: string | undefined;
14
+ }, {
15
+ name: string;
16
+ flagged?: boolean | undefined;
17
+ note?: string | undefined;
18
+ dueDate?: string | undefined;
19
+ project?: string | undefined;
20
+ }>;
21
+ export declare const UpdateTaskInputSchema: z.ZodObject<{
22
+ taskId: z.ZodString;
23
+ name: z.ZodOptional<z.ZodString>;
24
+ note: z.ZodOptional<z.ZodString>;
25
+ flagged: z.ZodOptional<z.ZodBoolean>;
26
+ dueDate: z.ZodOptional<z.ZodEffects<z.ZodString, string, string>>;
27
+ }, "strip", z.ZodTypeAny, {
28
+ taskId: string;
29
+ flagged?: boolean | undefined;
30
+ name?: string | undefined;
31
+ note?: string | undefined;
32
+ dueDate?: string | undefined;
33
+ }, {
34
+ taskId: string;
35
+ flagged?: boolean | undefined;
36
+ name?: string | undefined;
37
+ note?: string | undefined;
38
+ dueDate?: string | undefined;
39
+ }>;
40
+ export declare const CompleteTaskInputSchema: z.ZodObject<{
41
+ taskId: z.ZodString;
42
+ }, "strip", z.ZodTypeAny, {
43
+ taskId: string;
44
+ }, {
45
+ taskId: string;
46
+ }>;
47
+ export declare const GetTasksInputSchema: z.ZodObject<{
48
+ filter: z.ZodOptional<z.ZodEnum<["flagged", "due_today", "all"]>>;
49
+ }, "strip", z.ZodTypeAny, {
50
+ filter?: "flagged" | "due_today" | "all" | undefined;
51
+ }, {
52
+ filter?: "flagged" | "due_today" | "all" | undefined;
53
+ }>;
54
+ export declare const SetConfigInputSchema: z.ZodObject<{
55
+ directSqlAccess: z.ZodOptional<z.ZodBoolean>;
56
+ taskLimit: z.ZodOptional<z.ZodNumber>;
57
+ }, "strip", z.ZodTypeAny, {
58
+ directSqlAccess?: boolean | undefined;
59
+ taskLimit?: number | undefined;
60
+ }, {
61
+ directSqlAccess?: boolean | undefined;
62
+ taskLimit?: number | undefined;
63
+ }>;
64
+ export type ValidatedCreateTaskInput = z.infer<typeof CreateTaskInputSchema>;
65
+ export type ValidatedUpdateTaskInput = z.infer<typeof UpdateTaskInputSchema>;
66
+ export type ValidatedCompleteTaskInput = z.infer<typeof CompleteTaskInputSchema>;
67
+ export type ValidatedGetTasksInput = z.infer<typeof GetTasksInputSchema>;
68
+ export type ValidatedSetConfigInput = z.infer<typeof SetConfigInputSchema>;
@@ -0,0 +1,49 @@
1
+ import { z } from "zod";
2
+ const MAX_NAME_LENGTH = 1000;
3
+ const MAX_NOTE_LENGTH = 10000;
4
+ const MAX_PROJECT_LENGTH = 500;
5
+ const MAX_TASK_ID_LENGTH = 100;
6
+ const ISO_DATE_REGEX = /^\d{4}-\d{2}-\d{2}$/;
7
+ const dateSchema = z.string().regex(ISO_DATE_REGEX, "Invalid date format. Use YYYY-MM-DD").refine((val) => {
8
+ const date = new Date(val);
9
+ return !isNaN(date.getTime());
10
+ }, { message: "Invalid date value" });
11
+ const taskIdSchema = z.string()
12
+ .min(1, "Task ID is required")
13
+ .max(MAX_TASK_ID_LENGTH, `Task ID must be at most ${MAX_TASK_ID_LENGTH} characters`)
14
+ .regex(/^[a-zA-Z0-9_-]+$/, "Task ID contains invalid characters");
15
+ export const CreateTaskInputSchema = z.object({
16
+ name: z.string()
17
+ .min(1, "Task name is required")
18
+ .max(MAX_NAME_LENGTH, `Task name must be at most ${MAX_NAME_LENGTH} characters`),
19
+ note: z.string()
20
+ .max(MAX_NOTE_LENGTH, `Note must be at most ${MAX_NOTE_LENGTH} characters`)
21
+ .optional(),
22
+ project: z.string()
23
+ .max(MAX_PROJECT_LENGTH, `Project name must be at most ${MAX_PROJECT_LENGTH} characters`)
24
+ .optional(),
25
+ flagged: z.boolean().optional(),
26
+ dueDate: dateSchema.optional(),
27
+ });
28
+ export const UpdateTaskInputSchema = z.object({
29
+ taskId: taskIdSchema,
30
+ name: z.string()
31
+ .min(1, "Task name cannot be empty")
32
+ .max(MAX_NAME_LENGTH, `Task name must be at most ${MAX_NAME_LENGTH} characters`)
33
+ .optional(),
34
+ note: z.string()
35
+ .max(MAX_NOTE_LENGTH, `Note must be at most ${MAX_NOTE_LENGTH} characters`)
36
+ .optional(),
37
+ flagged: z.boolean().optional(),
38
+ dueDate: dateSchema.optional(),
39
+ });
40
+ export const CompleteTaskInputSchema = z.object({
41
+ taskId: taskIdSchema,
42
+ });
43
+ export const GetTasksInputSchema = z.object({
44
+ filter: z.enum(["flagged", "due_today", "all"]).optional(),
45
+ });
46
+ export const SetConfigInputSchema = z.object({
47
+ directSqlAccess: z.boolean().optional(),
48
+ taskLimit: z.number().int().min(1).max(10000).optional(),
49
+ });
@@ -0,0 +1,2 @@
1
+ export type OmniFocusVersion = "pro" | "standard";
2
+ export declare function detectVersion(): Promise<OmniFocusVersion>;
@@ -0,0 +1,47 @@
1
+ import { spawn } from "child_process";
2
+ function runAppleScriptCheck(script) {
3
+ return new Promise((resolve) => {
4
+ const child = spawn("osascript", ["-"], {
5
+ stdio: ["pipe", "pipe", "pipe"],
6
+ });
7
+ let stderr = "";
8
+ child.stderr.on("data", (data) => {
9
+ stderr += data.toString();
10
+ });
11
+ child.on("close", (code) => {
12
+ resolve({ success: code === 0, stderr });
13
+ });
14
+ child.on("error", () => {
15
+ resolve({ success: false, stderr: "spawn error" });
16
+ });
17
+ child.stdin.write(script);
18
+ child.stdin.end();
19
+ });
20
+ }
21
+ const VERSION_DETECTION_TIMEOUT_MS = 5000;
22
+ async function detectVersionInternal() {
23
+ const script = 'tell application "OmniFocus" to get name of first flattened task';
24
+ const result = await runAppleScriptCheck(script);
25
+ if (result.success) {
26
+ return "pro";
27
+ }
28
+ // error -1743 = scripting not authorized (Standard version)
29
+ if (result.stderr.includes("-1743")) {
30
+ return "standard";
31
+ }
32
+ // if no tasks exist, AppleScript still works - it's Pro
33
+ if (result.stderr.includes("Can't get")) {
34
+ return "pro";
35
+ }
36
+ // default to standard if we can't determine
37
+ return "standard";
38
+ }
39
+ export async function detectVersion() {
40
+ const timeout = new Promise((resolve) => {
41
+ setTimeout(() => {
42
+ console.error("[mcp-omnifocus] Version detection timed out, falling back to standard");
43
+ resolve("standard");
44
+ }, VERSION_DETECTION_TIMEOUT_MS);
45
+ });
46
+ return Promise.race([detectVersionInternal(), timeout]);
47
+ }
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "mcp-omnifocus",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for OmniFocus with auto-detection of Pro/Standard version",
5
+ "author": "Mikhail Lihachev",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/avlihachev/mcp-omnifocus"
10
+ },
11
+ "keywords": [
12
+ "mcp",
13
+ "omnifocus",
14
+ "claude",
15
+ "anthropic",
16
+ "productivity",
17
+ "task-management"
18
+ ],
19
+ "type": "module",
20
+ "main": "dist/index.js",
21
+ "bin": {
22
+ "mcp-omnifocus": "dist/index.js"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "scripts": {
30
+ "build": "tsc",
31
+ "dev": "tsc --watch",
32
+ "start": "node dist/index.js",
33
+ "test": "vitest run",
34
+ "test:watch": "vitest",
35
+ "test:coverage": "vitest run --coverage",
36
+ "prepublishOnly": "npm run build && npm test"
37
+ },
38
+ "dependencies": {
39
+ "@modelcontextprotocol/sdk": "^1.0.0",
40
+ "better-sqlite3": "^11.0.0",
41
+ "zod": "^3.23.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/better-sqlite3": "^7.6.0",
45
+ "@types/node": "^20.0.0",
46
+ "@vitest/coverage-v8": "^4.0.17",
47
+ "typescript": "^5.0.0",
48
+ "vitest": "^4.0.17"
49
+ },
50
+ "engines": {
51
+ "node": ">=18"
52
+ }
53
+ }