Skip to main content
Glama
estrenuo

OmniFocus MCP Server

by estrenuo
README.md
# OmniFocus MCP Server

A Model Context Protocol (MCP) server that enables AI assistants to interact with OmniFocus on macOS via JXA (JavaScript for Automation).

## Features

This MCP server provides access to OmniFocus functionality:

### Task Management
- **List inbox tasks** - View and filter tasks in your inbox (including multi-tag filtering)
- **Create tasks** - Add new tasks with full property support (due dates, planned dates, tags, notes, subtasks, recurrence)
- **Update tasks** - Change name, note, dates, flag, estimate, recurrence, or move a task to another project
- **Complete/Drop tasks** - Mark tasks as done or dropped, individually or in batch
- **Delete tasks** - Remove a task permanently
- **Update task notes** - Replace, clear, or append to a task's note
- **Get due tasks** - Find tasks due within a timeframe
- **Get planned tasks** - Find tasks planned within a timeframe
- **Get flagged tasks** - List all flagged items
- **Add/remove tags from tasks** - Manage task tags, individually or in batch

### Project Management
- **List projects** - View projects with status filtering
- **Get project tasks** - List all tasks belonging to a project
- **Create projects** - New projects with folder placement, status, dates, sequential mode, review interval
- **Update projects** - Change name, note, status, flag, dates, sequential mode, review interval
- **Delete projects** - Remove a project and its tasks
- **Update project notes** - Replace, clear, or append to a project's note
- **Get projects for review** - Find projects needing review, optionally with their incomplete tasks
- **Mark project reviewed** - Update a project's review status and next review date
- **Batch mark reviewed** - Efficiently review multiple projects at once

### Organization
- **List folders** - View folder hierarchy
- **Create/rename/delete folders** - Manage the folder tree (including nested folders)
- **List tags** - View all tags
- **List perspectives** - View built-in and custom perspectives
- **Get perspective tasks** - List tasks shown in a specific perspective

### Search
- **Universal search** - Search across tasks, projects, folders, and tags

### Safety properties
- **No silent winner on duplicate names.** OmniFocus allows two projects (or tasks) to share a name. Name lookups collect *every* match and fail with the matching IDs when there is more than one, so a rename, move or delete can never hit the wrong item while reporting success.
- **Mutations are verified.** Operations that JXA can fail silently at (notably moving a task between projects) read the result back inside the same script, so a failed move is reported as an error rather than as success.

## Requirements

- **macOS** (OmniFocus is macOS/iOS only, and this server uses JXA)
- **OmniFocus 3+** installed
- **Node.js 18+**
- **Automation permissions** enabled for your terminal/client app

## Installation

1. Clone or download this repository:
   ```bash
   cd omnifocus-mcp-server
   ```

2. Install dependencies:
   ```bash
   npm install
   ```

3. Build the TypeScript:
   ```bash
   npm run build
   ```

4. Configure your MCP client to use the server (see Configuration below)

## Configuration

### Claude Desktop

Add to your Claude Desktop configuration file (`~/Library/Application Support/Claude/claude_desktop_config.json`):

```json
{
  "mcpServers": {
    "omnifocus": {
      "command": "/opt/homebrew/bin/node",
      "args": ["/path/to/omnifocus-mcp-server/dist/index.js"]
    }
  }
}
```

Use an **absolute path to the `node` binary**. A bare `"node"` resolves against the GUI session's `PATH`, which does not include Homebrew or a version manager's shims, so the server fails to start with a "server unreachable"-style error even though it runs fine from your terminal. Find yours with `which node`.

### Other MCP Clients

The server uses stdio transport by default, so configure your client to spawn:
```
node /path/to/omnifocus-mcp-server/dist/index.js
```

### Remote access (HTTP transport)

For remote clients — most importantly claude.ai custom connectors, which is how the Claude iOS app reaches MCP servers — the server can run as a Streamable HTTP endpoint:

```bash
MCP_TRANSPORT=http \
MCP_AUTH_TOKEN="$(openssl rand -hex 32)" \
node /path/to/omnifocus-mcp-server/dist/index.js
```

Environment variables:

| Variable | Default | Purpose |
|---|---|---|
| `MCP_TRANSPORT` | `stdio` | Set to `http` to enable the HTTP transport |
| `MCP_HTTP_PORT` | `3000` | Port to listen on |
| `MCP_HTTP_HOST` | `127.0.0.1` | Bind address (keep loopback; expose via a tunnel) |
| `MCP_AUTH_TOKEN` | — | Required shared secret; the server refuses to start without it |
| `MCP_PUBLIC_URL` | — | Public HTTPS origin the server is reachable at (e.g. `https://your-tunnel-host`). Set this to enable the OAuth layer needed by claude.ai / Claude Desktop's "Connectors" UI — see below. Leave unset for direct/programmatic clients, which only need the static token. |
| `OMNIFOCUS_SCRIPT_TIMEOUT_MS` | `60000` | Kill a JXA script that hangs (applies to both transports) |

The MCP endpoint is `/mcp`. Authentication accepts either an `Authorization: Bearer <token>` header, or the token as a path segment (`/mcp/<token>`) for clients that cannot send custom headers. `GET /health` is unauthenticated.

**Reaching it from claude.ai / the iOS app.** Custom connectors connect from Anthropic's cloud (not from your device), so the endpoint must be publicly reachable over HTTPS. A [Cloudflare Tunnel](https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/) or a [Tailscale Funnel](https://tailscale.com/kb/1223/funnel) both work for this — either way, a local process makes an *outbound* connection, so no ports are opened. (Plain Tailscale, without Funnel, does not: that only reaches your own tailnet, and Anthropic's cloud isn't on it.) Optionally restrict access to Anthropic's outbound IP range (`160.79.104.0/21`) in a Cloudflare WAF rule.

claude.ai's and Claude Desktop's "Connectors" UI (as opposed to a raw MCP config / direct API client) always drives a full OAuth handshake before it will call a remote server — it does not accept the static token by itself, even embedded in the URL. Set `MCP_PUBLIC_URL` to your tunnel's public origin to turn on a self-issued OAuth layer that satisfies this while still gating access with the same static secret (see `oauth.ts` / CLAUDE.md for how). With that set, add the connector in Settings → Connectors using the path-token URL (`https://your-tunnel-host/mcp/<token>`) — the "Connect" step will complete the OAuth handshake automatically. If your tunnel also proxies `/` to a different local service, make sure `/authorize`, `/token`, `/register`, and `/.well-known/*` are mapped to this server too, or the OAuth requests never arrive here.

The Mac must stay awake with OmniFocus running (`caffeinate -s` or Amphetamine).

**Session semantics — single client only.** The HTTP transport serves one session at a time: a new `initialize` replaces the previous session. Every tool call is stateless, and a spec-compliant client re-initializes when it receives 404 for a session that no longer exists, so a single client calling sequentially works fine. That 404 also covers the case where the server holds no session at all, which is what happens to every client after the server restarts — they re-initialize instead of treating it as a dead endpoint.

Two clients at once do *not*. Reproduced by firing two concurrent `initialize`→`tools/list` sequences at the public endpoint: one consistently 404s. The underlying MCP SDK binds a single transport to the shared server instance, so the losing session is evicted and its in-flight request either 404s or hangs. Fixing this requires a per-session `McpServer` instance rather than the current registration-on-a-singleton pattern; it is not currently planned. See CLAUDE.md for the details.

**Troubleshooting a "can't connect" client.** Clients collapse every remote failure into one vague message, so read this server's log (`StandardErrorPath` of your launch agent) rather than the client's wording — the status code says which of three unrelated things went wrong:

| What the log shows | Meaning | Fix |
|---|---|---|
| `↳ path token rejected: got N chars …` | The token in the client's URL is wrong or truncated | Re-copy `<MCP_PUBLIC_URL>/mcp/<token>` whole; never retype it |
| `↳ authorize rejected: resource … !== …` | Same cause, seen from the OAuth side. A `/authorize → 302` with no `/token` after it always means this | As above |
| `[…] → 404` then a fresh `initialize` | Normal recovery after a restart or a session takeover | Nothing; the client re-initializes itself |
| `[initialize] → 200 — client: …` | Working. The client name identifies which one | — |

If nothing appears in the log at all, the request never reached this server: check the tunnel's path mappings, not this code.

## Permissions

On first use, macOS will prompt you to allow automation access:

1. Go to **System Preferences** → **Security & Privacy** → **Privacy** → **Automation**
2. Enable permission for your terminal or Claude Desktop to control OmniFocus

## Tool Reference

All 31 tools are listed below, grouped by area.

### omnifocus_list_inbox
List tasks in the inbox, optionally filtered by tags.
```json
{
  "includeCompleted": false,
  "limit": 50,
  "tags": ["Work", "Urgent"],
  "tagMatchMode": "all"
}
```
`tagMatchMode` is `"all"` (task has every listed tag, default), `"any"` (at least one), or `"none"` (none of them). It only applies when `tags` is given. The same two parameters work on `omnifocus_get_due_tasks`, `omnifocus_get_flagged_tasks` and `omnifocus_get_planned_tasks`.

### omnifocus_list_projects
List projects with filtering.
```json
{
  "status": "active",
  "folderName": "Work",
  "limit": 50
}
```

### omnifocus_get_project_tasks
Get all tasks belonging to one project.
```json
{
  "projectId": "abc123",
  "includeCompleted": false,
  "limit": 100
}
```

### omnifocus_create_project
Create a project, optionally inside a folder.
```json
{
  "name": "Website redesign",
  "note": "Q1 initiative",
  "folderName": "Work",
  "dueDate": "2024-03-31T17:00:00",
  "deferDate": "2024-01-15T09:00:00",
  "flagged": false,
  "sequential": false,
  "status": "active",
  "reviewIntervalDays": 7
}
```
`status` is `"active"` (default), `"on hold"`, `"done"` or `"dropped"`. `sequential: false` (default) makes a parallel project.

### omnifocus_update_project
Update project properties. Identify by `projectId` or `projectName` (ID wins).
```json
{
  "projectId": "abc123",
  "name": "Website redesign v2",
  "status": "on hold",
  "flagged": true,
  "dueDate": null,
  "sequential": true,
  "reviewIntervalDays": 14
}
```
Pass `null` for `note`, `dueDate` or `deferDate` to clear them. A project **cannot** be moved to another folder (JXA limitation).

### omnifocus_delete_project
Delete a project and its tasks. Identify by `projectId` or `projectName` (ID wins).
```json
{
  "projectId": "abc123"
}
```

### omnifocus_list_folders
List all folders.
```json
{
  "status": "active",
  "limit": 50
}
```

### omnifocus_create_folder
Create a folder, top-level or nested.
```json
{
  "name": "Clients",
  "parentFolderName": "Work"
}
```

### omnifocus_update_folder
Rename a folder. Identify by `folderId` or `folderName` (ID wins). A folder **cannot** be moved into another folder (JXA limitation).
```json
{
  "folderName": "Clients",
  "name": "Key clients"
}
```

### omnifocus_delete_folder
Delete a folder and everything in it. Identify by `folderId` or `folderName` (ID wins).
```json
{
  "folderId": "abc123"
}
```

### omnifocus_list_tags
List all tags.
```json
{
  "status": "active",
  "limit": 50
}
```

### omnifocus_list_perspectives
List perspectives (built-in and custom).
```json
{
  "limit": 50
}
```

### omnifocus_get_perspective_tasks
Get tasks shown in a specific perspective.
```json
{
  "perspectiveName": "Next",
  "limit": 50
}
```

### omnifocus_create_task
Create a new task.
```json
{
  "name": "Review quarterly report",
  "note": "Check all sections",
  "projectName": "Work",
  "dueDate": "2024-12-31T17:00:00",
  "deferDate": "2024-12-01T09:00:00",
  "plannedDate": "2024-12-15T09:00:00",
  "flagged": true,
  "estimatedMinutes": 60,
  "tagNames": ["Review", "Important"],
  "parentTaskId": "xyz789",
  "recurrence": {
    "frequency": "weekly",
    "interval": 1,
    "daysOfWeek": ["Monday", "Thursday"],
    "repeatFrom": "due-date"
  }
}
```

**Planned Date vs Due Date:**
- `dueDate`: When the task must be completed (deadline)
- `plannedDate`: When you intend to work on the task (planning)
- This distinction is crucial for separating deadlines from scheduled work time

**Recurrence:** `frequency` is `"daily"`, `"weekly"`, `"monthly"` or `"yearly"`. Use `daysOfWeek` for weekly, `dayOfMonth` (1-31) for monthly, `monthOfYear` (1-12) for yearly. `repeatFrom` is `"due-date"` (default) or `"completion-date"`.

**Subtasks:** pass `parentTaskId` to create the task as a child of an existing task.

### omnifocus_update_task
Update an existing task. Identify by `taskId` or `taskName` (ID wins).
```json
{
  "taskId": "abc123",
  "name": "Review quarterly report (final)",
  "note": null,
  "dueDate": "2024-12-20T17:00:00",
  "flagged": true,
  "estimatedMinutes": 45,
  "projectName": "Work"
}
```
- Pass `null` for `note`, `dueDate`, `deferDate` or `plannedDate` to clear them; `estimatedMinutes: 0` clears the estimate.
- `projectId` / `projectName` **moves** the task to that project (subtasks come along). The move is verified afterwards, so a failure is reported as an error instead of a false success.
- `recurrence` takes the same object as `create_task`; `recurrence: null` or `clearRecurrence: true` turns repetition off.

### omnifocus_delete_task
Delete a task. Identify by `taskId` or `taskName` (ID wins).
```json
{
  "taskId": "abc123"
}
```

### omnifocus_update_task_note
Replace, clear, or append to a task's note. Identify by `taskId` or `taskName` (ID wins).
```json
{
  "taskId": "abc123",
  "note": "Added after the call.",
  "append": true
}
```
An empty `note` string clears the note.

### omnifocus_complete_task
Mark a task as complete or dropped. You can identify the task by either ID or name.
```json
{
  "taskId": "abc123",
  "action": "complete"
}
```
Or using task name:
```json
{
  "taskName": "Write documentation",
  "action": "complete"
}
```
Action can be `"complete"` (default) or `"drop"`. If both `taskId` and `taskName` are provided, `taskId` takes priority.

Dropping a repeating task clears its repetition rule first, so the series really stops instead of rolling forward to the next occurrence.

### omnifocus_batch_complete_task
Complete or drop up to 100 tasks by ID in one call.
```json
{
  "taskIds": ["id1", "id2", "id3"],
  "action": "complete"
}
```

### omnifocus_add_tag_to_task
Add a tag to a task. You can identify the task by either ID or name.
```json
{
  "taskId": "abc123",
  "tagName": "Urgent"
}
```
Or using task name:
```json
{
  "taskName": "Write report",
  "tagName": "Urgent"
}
```
If both `taskId` and `taskName` are provided, `taskId` takes priority.

### omnifocus_remove_tag_from_task
Remove a tag from a task. You can identify the task by either ID or name.
```json
{
  "taskId": "abc123",
  "tagName": "Urgent"
}
```
Or using task name:
```json
{
  "taskName": "Old task",
  "tagName": "Done"
}
```
If both `taskId` and `taskName` are provided, `taskId` takes priority.

### omnifocus_batch_add_tag
Add one existing tag to up to 100 tasks by ID.
```json
{
  "taskIds": ["id1", "id2", "id3"],
  "tagName": "Urgent"
}
```

### omnifocus_batch_remove_tag
Remove one tag from up to 100 tasks by ID.
```json
{
  "taskIds": ["id1", "id2", "id3"],
  "tagName": "Urgent"
}
```

### omnifocus_update_project_note
Replace, clear, or append to a project's note. Identify by `projectId` or `projectName` (ID wins).
```json
{
  "projectName": "Website redesign",
  "note": "Kickoff moved to March.",
  "append": false
}
```

### omnifocus_search
Search across OmniFocus.
```json
{
  "query": "report",
  "searchType": "all",
  "limit": 20
}
```

### omnifocus_get_due_tasks
Get tasks due within a timeframe.
```json
{
  "daysAhead": 7,
  "includeOverdue": true,
  "limit": 50
}
```

### omnifocus_get_flagged_tasks
Get flagged tasks.
```json
{
  "includeCompleted": false,
  "limit": 50
}
```

### omnifocus_get_planned_tasks
Get tasks planned within a timeframe.
```json
{
  "daysAhead": 7,
  "includeOverdue": true,
  "limit": 50
}
```

### omnifocus_get_projects_for_review
Get projects that need review based on their next review date. Perfect for GTD practitioners following the review workflow.
```json
{
  "daysAhead": 0,
  "status": "active",
  "limit": 50,
  "includeTasks": true,
  "taskLimit": 50
}
```
Parameters:
- `daysAhead`: How many days ahead to look (0 = overdue reviews only)
- `status`: Filter by project status ("active", "done", "dropped", "onHold", "all")
- `limit`: Maximum number of projects to return (1-500)
- `includeTasks`: Include each project's incomplete tasks in the result (default false) — this turns one review pass into a single call instead of one follow-up call per project
- `taskLimit`: Maximum tasks per project when `includeTasks` is true (1-200, default 50)

Each project also returns `reviewInterval` and `lastReviewDate`.

### omnifocus_mark_project_reviewed
Mark a project as reviewed and update its next review date. You can identify the project by either ID or name.
```json
{
  "projectId": "abc123"
}
```
Or using project name:
```json
{
  "projectName": "Weekly Review"
}
```
With custom review interval:
```json
{
  "projectName": "Work Project",
  "reviewIntervalDays": 14
}
```
Parameters:
- `projectId` or `projectName`: Identifies the project (ID takes priority)
- `reviewIntervalDays` (optional): Custom review interval in days. If not provided, uses the project's existing review interval.

### omnifocus_batch_mark_reviewed
Mark multiple projects as reviewed in one efficient operation.
```json
{
  "projectIds": ["id1", "id2", "id3"]
}
```
With custom review interval for all:
```json
{
  "projectIds": ["id1", "id2", "id3"],
  "reviewIntervalDays": 7
}
```
Parameters:
- `projectIds`: Array of project IDs to mark as reviewed (1-100 projects)
- `reviewIntervalDays` (optional): Custom review interval to apply to all projects

Returns a summary with:
- Count of successful reviews
- Count of failures
- Full project data for successful reviews
- Error details for any failures

## Date Formats

All dates use ISO 8601 format: `YYYY-MM-DDTHH:mm:ss`

Examples:
- `2024-12-31T17:00:00` - December 31, 2024 at 5:00 PM
- `2024-06-15T09:00:00` - June 15, 2024 at 9:00 AM

## Error Handling

The server provides clear error messages for common issues:

- **OmniFocus not running**: Launch OmniFocus first
- **Permission denied**: Enable automation permissions in System Preferences
- **Item not found**: The specified ID doesn't exist
- **Invalid parameters**: Check parameter format and values

## Development

### Build
```bash
npm run build
```

### Watch mode
```bash
npm run dev
```

### Tests
```bash
npm test              # All unit tests
npm run test:watch    # Watch mode
npm run test:coverage # Coverage report (thresholds enforced: 80% lines, 75% branches)
```

The integration tests in `src/__tests__/integration.test.ts` are skipped by default: they require a running OmniFocus and they modify your real database.

### Test manually
After building, you can test with:
```bash
echo '{"jsonrpc":"2.0","method":"tools/list","id":1}' | node dist/index.js
```

### Applying changes to a running server (important)

MCP clients fetch the tool list **once when they connect** and cache it for the
session. `npm run build` alone does not update a client that is already
connected — Node does not hot-reload, and the client will not re-fetch the
schema. After changing tools/schemas you must both restart the server and make
each client reconnect:

1. **Rebuild:** `npm run build`
2. **Restart the server process** so it loads the new `dist/`:
   - LaunchAgent (HTTP transport): `launchctl kickstart -k gui/$(id -u)/com.sanderrobijns.omnifocus-mcp`
   - Verify it serves the new schema: `lsof -nP -iTCP:3000 -sTCP:LISTEN` should show a freshly started PID.
3. **Reconnect each client** so it re-fetches `tools/list`:
   - **Claude Code / Cowork:** start a **new session** (a running session keeps its cached schema for its whole lifetime).
   - **Claude Desktop:** quit and reopen the app (or toggle the server off/on).
   - **claude.ai / Claude iOS custom connector:** re-sync the connector in Settings → Connectors (it caches the tool list at the connector level).

Until the client reconnects it keeps showing the old schema, even though the
server already serves the new one.

## License

MIT

## Credits

Built using:
- [Model Context Protocol SDK](https://github.com/modelcontextprotocol/typescript-sdk)
- [OmniFocus JXA API](https://developer.apple.com/library/archive/releasenotes/InterapplicationCommunication/RN-JavaScriptForAutomation/)

TDQS

A4/5.0

Scored across 31 tools

Disambiguation4/5

Most tools target distinct resource-action pairs, but there is overlap between general update tools and dedicated note update tools (e.g., omnifocus_update_task vs omnifocus_update_task_note), which could confuse an agent deciding which to use.

Naming Consistency5/5

All tools follow a consistent omnifocus_verb_noun pattern with clear verbs (list_, get_, create_, update_, delete_, etc.) and consistent prepositions (add_tag_to_task, remove_tag_from_task), making the naming predictable and easy to navigate.

Tool Count4/5

31 tools is on the higher side, but the number is justified by the comprehensive feature set of OmniFocus (tasks, projects, folders, tags, perspectives, reviews, batch operations). A small reduction by merging note update tools would improve conciseness.

Completeness3/5

The surface covers most CRUD operations for tasks, projects, and folders, including tagging and review. However, missing tag creation and a general 'list all tasks' tool are notable gaps that may cause agent failures.

Maintenance

ActivityMaintained
ResponsivenessSyncing