omnifocus_mcp 1.0.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.
Files changed (108) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +15 -0
  3. data/CHANGELOG.md +7 -0
  4. data/CODE_OF_CONDUCT.md +16 -0
  5. data/LICENSE.txt +21 -0
  6. data/README.md +147 -0
  7. data/Rakefile +12 -0
  8. data/bin/omnifocus-mcp +7 -0
  9. data/lib/omnifocus_mcp/config.rb +18 -0
  10. data/lib/omnifocus_mcp/infrastructure/.keep +1 -0
  11. data/lib/omnifocus_mcp/infrastructure/apple_script.rb +263 -0
  12. data/lib/omnifocus_mcp/infrastructure/apple_script_date_builder.rb +65 -0
  13. data/lib/omnifocus_mcp/infrastructure/js_embed.rb +39 -0
  14. data/lib/omnifocus_mcp/infrastructure/script_runner.rb +254 -0
  15. data/lib/omnifocus_mcp/infrastructure.rb +6 -0
  16. data/lib/omnifocus_mcp/json_rpc_compat.rb +75 -0
  17. data/lib/omnifocus_mcp/logger.rb +34 -0
  18. data/lib/omnifocus_mcp/mcp.rb +74 -0
  19. data/lib/omnifocus_mcp/parsers/.keep +1 -0
  20. data/lib/omnifocus_mcp/parsers/apple_script_envelope.rb +44 -0
  21. data/lib/omnifocus_mcp/parsers.rb +6 -0
  22. data/lib/omnifocus_mcp/resources/base.rb +87 -0
  23. data/lib/omnifocus_mcp/resources/flagged_resource.rb +31 -0
  24. data/lib/omnifocus_mcp/resources/inbox_resource.rb +31 -0
  25. data/lib/omnifocus_mcp/resources/perspective_resource.rb +28 -0
  26. data/lib/omnifocus_mcp/resources/project_resource.rb +37 -0
  27. data/lib/omnifocus_mcp/resources/stats_resource.rb +22 -0
  28. data/lib/omnifocus_mcp/resources/today_resource.rb +37 -0
  29. data/lib/omnifocus_mcp/result.rb +108 -0
  30. data/lib/omnifocus_mcp/tools/batch_report.rb +9 -0
  31. data/lib/omnifocus_mcp/tools/database_stats.rb +184 -0
  32. data/lib/omnifocus_mcp/tools/definitions/add_omnifocus_task_tool.rb +61 -0
  33. data/lib/omnifocus_mcp/tools/definitions/add_project_tool.rb +54 -0
  34. data/lib/omnifocus_mcp/tools/definitions/batch_add_items_tool.rb +105 -0
  35. data/lib/omnifocus_mcp/tools/definitions/batch_remove_items_tool.rb +68 -0
  36. data/lib/omnifocus_mcp/tools/definitions/date_formatter.rb +45 -0
  37. data/lib/omnifocus_mcp/tools/definitions/edit_item_tool.rb +87 -0
  38. data/lib/omnifocus_mcp/tools/definitions/get_perspective_view_tool.rb +57 -0
  39. data/lib/omnifocus_mcp/tools/definitions/key_normalizer.rb +30 -0
  40. data/lib/omnifocus_mcp/tools/definitions/list_perspectives_tool.rb +47 -0
  41. data/lib/omnifocus_mcp/tools/definitions/list_tags_tool.rb +42 -0
  42. data/lib/omnifocus_mcp/tools/definitions/mcp_envelope.rb +31 -0
  43. data/lib/omnifocus_mcp/tools/definitions/operation_factory.rb +33 -0
  44. data/lib/omnifocus_mcp/tools/definitions/query_omnifocus_tool.rb +187 -0
  45. data/lib/omnifocus_mcp/tools/definitions/remove_item_tool.rb +55 -0
  46. data/lib/omnifocus_mcp/tools/generators/.keep +1 -0
  47. data/lib/omnifocus_mcp/tools/generators/add_omnifocus_task.rb +348 -0
  48. data/lib/omnifocus_mcp/tools/generators/add_project.rb +141 -0
  49. data/lib/omnifocus_mcp/tools/generators/database_stats.rb +16 -0
  50. data/lib/omnifocus_mcp/tools/generators/edit_item.rb +455 -0
  51. data/lib/omnifocus_mcp/tools/generators/list_perspectives.rb +13 -0
  52. data/lib/omnifocus_mcp/tools/generators/list_tags.rb +13 -0
  53. data/lib/omnifocus_mcp/tools/generators/perspective_view.rb +17 -0
  54. data/lib/omnifocus_mcp/tools/generators/query_omnifocus.rb +571 -0
  55. data/lib/omnifocus_mcp/tools/generators/query_omnifocus_debug.rb +169 -0
  56. data/lib/omnifocus_mcp/tools/generators/remove_item.rb +61 -0
  57. data/lib/omnifocus_mcp/tools/generators.rb +8 -0
  58. data/lib/omnifocus_mcp/tools/messages/add_omnifocus_task.rb +53 -0
  59. data/lib/omnifocus_mcp/tools/messages/add_project.rb +28 -0
  60. data/lib/omnifocus_mcp/tools/messages/batch_remove_items.rb +13 -0
  61. data/lib/omnifocus_mcp/tools/messages/edit_item.rb +39 -0
  62. data/lib/omnifocus_mcp/tools/messages/list_tools.rb +15 -0
  63. data/lib/omnifocus_mcp/tools/messages/remove_item.rb +42 -0
  64. data/lib/omnifocus_mcp/tools/messages.rb +8 -0
  65. data/lib/omnifocus_mcp/tools/operations/add_omnifocus_task.rb +74 -0
  66. data/lib/omnifocus_mcp/tools/operations/add_project.rb +75 -0
  67. data/lib/omnifocus_mcp/tools/operations/batch_add_items/batch_item.rb +38 -0
  68. data/lib/omnifocus_mcp/tools/operations/batch_add_items/bulk_executor.rb +94 -0
  69. data/lib/omnifocus_mcp/tools/operations/batch_add_items/cycle_detector.rb +74 -0
  70. data/lib/omnifocus_mcp/tools/operations/batch_add_items/param_builder.rb +47 -0
  71. data/lib/omnifocus_mcp/tools/operations/batch_add_items/planner.rb +111 -0
  72. data/lib/omnifocus_mcp/tools/operations/batch_add_items.rb +149 -0
  73. data/lib/omnifocus_mcp/tools/operations/batch_remove_items.rb +49 -0
  74. data/lib/omnifocus_mcp/tools/operations/database_stats.rb +52 -0
  75. data/lib/omnifocus_mcp/tools/operations/edit_item.rb +79 -0
  76. data/lib/omnifocus_mcp/tools/operations/get_perspective_view.rb +112 -0
  77. data/lib/omnifocus_mcp/tools/operations/list_perspectives.rb +85 -0
  78. data/lib/omnifocus_mcp/tools/operations/list_tags.rb +80 -0
  79. data/lib/omnifocus_mcp/tools/operations/query_omnifocus.rb +74 -0
  80. data/lib/omnifocus_mcp/tools/operations/query_omnifocus_debug.rb +63 -0
  81. data/lib/omnifocus_mcp/tools/operations/remove_item.rb +75 -0
  82. data/lib/omnifocus_mcp/tools/operations.rb +8 -0
  83. data/lib/omnifocus_mcp/tools/params/mcp_boundary.rb +41 -0
  84. data/lib/omnifocus_mcp/tools/params.rb +106 -0
  85. data/lib/omnifocus_mcp/tools/presenters/batch_report.rb +55 -0
  86. data/lib/omnifocus_mcp/tools/presenters/list_perspectives.rb +33 -0
  87. data/lib/omnifocus_mcp/tools/presenters/list_tags.rb +49 -0
  88. data/lib/omnifocus_mcp/tools/presenters/perspective_view.rb +81 -0
  89. data/lib/omnifocus_mcp/tools/presenters/query_reply.rb +52 -0
  90. data/lib/omnifocus_mcp/tools/presenters/query_results.rb +183 -0
  91. data/lib/omnifocus_mcp/tools/presenters.rb +8 -0
  92. data/lib/omnifocus_mcp/tools/query_omnifocus_formatter.rb +9 -0
  93. data/lib/omnifocus_mcp/tools/query_statuses.rb +22 -0
  94. data/lib/omnifocus_mcp/utils/apple_script.rb +9 -0
  95. data/lib/omnifocus_mcp/utils/apple_script_envelope.rb +9 -0
  96. data/lib/omnifocus_mcp/utils/apple_script_helpers.rb +9 -0
  97. data/lib/omnifocus_mcp/utils/blank.rb +26 -0
  98. data/lib/omnifocus_mcp/utils/date_filter.rb +76 -0
  99. data/lib/omnifocus_mcp/utils/date_formatting.rb +9 -0
  100. data/lib/omnifocus_mcp/utils/iso_date.rb +27 -0
  101. data/lib/omnifocus_mcp/utils/omnifocus_scripts/getPerspectiveView.js +472 -0
  102. data/lib/omnifocus_mcp/utils/omnifocus_scripts/listPerspectives.js +59 -0
  103. data/lib/omnifocus_mcp/utils/omnifocus_scripts/listTags.js +58 -0
  104. data/lib/omnifocus_mcp/utils/omnifocus_scripts/omnifocusDump.js +223 -0
  105. data/lib/omnifocus_mcp/utils/script_execution.rb +9 -0
  106. data/lib/omnifocus_mcp/version.rb +5 -0
  107. data/lib/omnifocus_mcp.rb +102 -0
  108. metadata +166 -0
@@ -0,0 +1,254 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "tempfile"
6
+
7
+ require_relative "../config"
8
+ require_relative "../result"
9
+ require_relative "../utils/blank"
10
+ require_relative "js_embed"
11
+
12
+ module OmnifocusMcp
13
+ module Infrastructure
14
+ # Runs OmniFocus automation scripts (JXA, OmniJS, AppleScript) via
15
+ # `osascript`.
16
+ #
17
+ # Instances accept an injectable runner so unit specs can supply a fake that
18
+ # returns canned `[stdout, stderr, status]` triples without invoking
19
+ # osascript. Class methods delegate to a default singleton for compatibility
20
+ # with the previous `Utils::ScriptExecution` API.
21
+ class ScriptRunner
22
+ OMNIFOCUS_SCRIPTS_DIR = File.expand_path("../utils/omnifocus_scripts", __dir__).freeze
23
+ STDOUT_PREVIEW_LIMIT = 200
24
+
25
+ class << self
26
+ def default = @default ||= new
27
+
28
+ def runner = default.runner
29
+
30
+ def runner=(runner)
31
+ default.runner = runner
32
+ end
33
+
34
+ def reset!
35
+ @default = new
36
+ end
37
+
38
+ def execute_jxa(script) = default.execute_jxa(script)
39
+ def execute_omnifocus_source(source, args: nil) = default.execute_omnifocus_source(source, args: args)
40
+ def execute_omnifocus_script(script_path, args: nil) = default.execute_omnifocus_script(script_path, args: args)
41
+ def execute_applescript(source) = default.execute_applescript(source)
42
+ def with_temp_script(content:, prefix:, ext:, &) = default.with_temp_script(content:, prefix:, ext:, &)
43
+ def escape_content(content) = default.escape_content(content)
44
+ def resolve_script_path(script_path) = default.resolve_script_path(script_path)
45
+ def capture_osascript(...) = default.capture_osascript(...)
46
+ end
47
+
48
+ attr_writer :runner
49
+
50
+ def initialize(runner: nil)
51
+ @runner = runner
52
+ end
53
+
54
+ def runner = @runner ||= method(:capture_osascript).to_proc
55
+
56
+ # Execute a JXA script (string source) and return {Result} with the parsed
57
+ # JSON value.
58
+ def execute_jxa(script)
59
+ run_jxa_source_result(source: script, prefix: "jxa_script")
60
+ .and_then { |stdout| parse_jxa_output(stdout) }
61
+ end
62
+
63
+ # Execute an OmniJS script source (string) inside OmniFocus via
64
+ # `app.evaluateJavascript`. Returns {Result} with the parsed JSON value.
65
+ #
66
+ # `args` (Array<String>) is prepended as a `const argv = [...]` block
67
+ # before the script body.
68
+ def execute_omnifocus_source(source, args: nil)
69
+ wrap_omnifocus_source(source:, args:).then do |wrapped|
70
+ run_jxa_source_result(source: wrapped, prefix: "jxa_wrapper")
71
+ .and_then { |stdout| parse_omnifocus_output(stdout) }
72
+ end
73
+ end
74
+
75
+ # Execute an OmniJS script from disk inside OmniFocus.
76
+ #
77
+ # `script_path` may be a real filesystem path or an `@scriptName.js`
78
+ # shorthand that resolves against `OMNIFOCUS_SCRIPTS_DIR`.
79
+ def execute_omnifocus_script(script_path, args: nil)
80
+ # Force UTF-8: bundled OmniJS files may contain non-ASCII bytes; the
81
+ # platform default of US-ASCII would otherwise raise inside the
82
+ # regex-based escape pass.
83
+ File.read(resolve_script_path(script_path), encoding: Encoding::UTF_8).then do |source|
84
+ execute_omnifocus_source(source, args:)
85
+ end
86
+ end
87
+
88
+ # Execute a raw AppleScript source string via `osascript`.
89
+ #
90
+ # Returns the `[stdout, stderr, status]` triple from the runner so callers
91
+ # can surface stderr/status without re-running the script.
92
+ def execute_applescript(source)
93
+ with_temp_script(content: source, prefix: "applescript", ext: "applescript") do |path|
94
+ runner.call("osascript", path)
95
+ end
96
+ end
97
+
98
+ # Materialize `content` to a tempfile, yield its path to the block, and
99
+ # guarantee cleanup (even on exception). Uses `Tempfile.create`, which
100
+ # removes the file when the block exits.
101
+ def with_temp_script(content:, prefix:, ext:)
102
+ Tempfile.create([prefix, ".#{ext}"]) do |file|
103
+ file.write(content)
104
+ file.flush
105
+ yield file.path
106
+ end
107
+ end
108
+
109
+ # Escape a string for safe embedding in a JXA template literal.
110
+ def escape_content(content) = JsEmbed.template_literal(content)
111
+
112
+ # Resolve `@scriptName.js` to an absolute path inside the gem's bundled
113
+ # OmniJS directory. Plain paths pass through unchanged.
114
+ def resolve_script_path(script_path)
115
+ return script_path unless script_path.start_with?("@")
116
+
117
+ File.join(OMNIFOCUS_SCRIPTS_DIR, script_path[1..])
118
+ end
119
+
120
+ # Runs `osascript` (or a test double) with an optional timeout so a hung
121
+ # automation call cannot block the MCP server indefinitely.
122
+ def capture_osascript(*argv)
123
+ timeout_sec = Config.script_timeout_sec
124
+ return Open3.capture3(*argv) unless timeout_sec
125
+
126
+ capture_osascript_with_timeout(*argv, timeout_sec:)
127
+ end
128
+
129
+ private
130
+
131
+ FailedStatus = Data.define(:exitstatus) do
132
+ def success? = false
133
+ end
134
+ private_constant :FailedStatus
135
+
136
+ SCRIPT_TIMEOUT_STATUS = FailedStatus.new(exitstatus: nil).freeze
137
+ private_constant :SCRIPT_TIMEOUT_STATUS
138
+
139
+ def capture_osascript_with_timeout(*argv, timeout_sec:)
140
+ Open3.popen3(*argv) do |_stdin, stdout, stderr, wait_thr|
141
+ out_thread = Thread.new { stdout.read }
142
+ err_thread = Thread.new { stderr.read }
143
+
144
+ unless wait_thr.join(timeout_sec)
145
+ terminate_osascript(wait_thr)
146
+ message = stderr_message(err_thread)
147
+ message = "osascript timed out after #{timeout_sec}s" if message.empty?
148
+ OmnifocusMcp.logger.warn("[script_execution] #{message}")
149
+ return ["", message, SCRIPT_TIMEOUT_STATUS]
150
+ end
151
+
152
+ [out_thread.value, err_thread.value, wait_thr.value]
153
+ end
154
+ end
155
+
156
+ def terminate_osascript(wait_thr)
157
+ pid = wait_thr.pid
158
+ Process.kill("TERM", pid)
159
+ wait_thr.join(2)
160
+ Process.kill("KILL", pid) unless wait_thr.status == false
161
+ rescue Errno::ESRCH
162
+ nil
163
+ ensure
164
+ wait_thr.join(1)
165
+ end
166
+
167
+ def stderr_message(err_thread)
168
+ return "" unless err_thread.join(1)
169
+
170
+ err_thread.value.to_s.strip
171
+ end
172
+
173
+ # Runs raw JXA `source` via `osascript -l JavaScript`, returning
174
+ # {Result.ok(stdout)} on success or {Result.error} when the runner fails
175
+ # or the process exits non-zero.
176
+ def run_jxa_source_result(source:, prefix:)
177
+ with_temp_script(content: source, prefix: prefix, ext: "js") do |path|
178
+ stdout, stderr, status = runner.call("osascript", "-l", "JavaScript", path)
179
+ return Result.error(format_run_failure(stderr, status)) unless status.success?
180
+
181
+ OmnifocusMcp.logger.warn("[script_execution] Script stderr output: #{stderr}") if stderr && !stderr.empty?
182
+ Result.ok(stdout)
183
+ end
184
+ rescue StandardError => e
185
+ Result.error("Failed to execute script: #{e.message}")
186
+ end
187
+
188
+ def format_run_failure(stderr, status)
189
+ exit_code = status.respond_to?(:exitstatus) ? status.exitstatus : status
190
+ message = "osascript failed (exit #{exit_code})"
191
+ message += ": #{stderr.strip}" unless stderr.nil? || stderr.empty?
192
+ message
193
+ end
194
+
195
+ # Build the JXA wrapper that calls `app.evaluateJavascript` with the
196
+ # OmniJS source embedded inside a backtick template literal.
197
+ # rubocop:disable Metrics/MethodLength
198
+ def wrap_omnifocus_source(source:, args:)
199
+ script_with_args =
200
+ if Utils::Blank.blank?(args)
201
+ source
202
+ else
203
+ quoted_args = args.map { |a| %("#{escape_content(a)}") }.join(", ")
204
+ "\n// Set up arguments\nconst argv = [#{quoted_args}];\n\n#{source}"
205
+ end
206
+
207
+ escaped = escape_content(script_with_args)
208
+
209
+ <<~JXA
210
+ function run() {
211
+ try {
212
+ const app = Application('OmniFocus');
213
+ app.includeStandardAdditions = true;
214
+
215
+ // Run the OmniJS script in OmniFocus and capture the output
216
+ const result = app.evaluateJavascript(`#{escaped}`);
217
+
218
+ // Return the result
219
+ return result;
220
+ } catch (e) {
221
+ return JSON.stringify({ error: e.message });
222
+ }
223
+ }
224
+ JXA
225
+ end
226
+ # rubocop:enable Metrics/MethodLength
227
+
228
+ # JXA stdout -> {Result.ok(parsed)} or {Result.error} on parse failure.
229
+ def parse_jxa_output(stdout)
230
+ Result.ok(parse_json_stdout(stdout))
231
+ rescue JSON::ParserError => e
232
+ Result.error(parse_failure_message(parse_error: e, stdout:))
233
+ end
234
+
235
+ # OmniJS stdout -> {Result.ok(parsed)} or {Result.error} on parse failure.
236
+ def parse_omnifocus_output(stdout)
237
+ Result.ok(parse_json_stdout(stdout))
238
+ rescue JSON::ParserError => e
239
+ Result.error(parse_failure_message(parse_error: e, stdout:))
240
+ end
241
+
242
+ # osascript may return US-ASCII-labelled stdout that contains UTF-8 bytes
243
+ # (e.g. tag names with accents). Treat stdout as UTF-8 before parsing.
244
+ def parse_json_stdout(stdout)
245
+ JSON.parse(stdout.to_s.dup.force_encoding(Encoding::UTF_8))
246
+ end
247
+
248
+ def parse_failure_message(parse_error:, stdout:)
249
+ preview = stdout.to_s[0, STDOUT_PREVIEW_LIMIT]
250
+ "Failed to parse script output (#{parse_error.message}): #{preview}"
251
+ end
252
+ end
253
+ end
254
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Infrastructure
5
+ end
6
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Cursor's MCP client rejects JSON-RPC error responses with `"id": null`
4
+ # (fast-mcp 1.6 emits null when the request id is unknown). Coerce those to 0
5
+ # and ignore blank stdin lines so empty writes do not produce invalid responses.
6
+ module OmnifocusMcp
7
+ module JsonRpcCompat
8
+ UNKNOWN_REQUEST_ID = 0
9
+
10
+ module_function
11
+
12
+ def apply!
13
+ patch_server_send_error!
14
+ patch_stdio_transport!
15
+ end
16
+
17
+ def normalize_id(id) = id.nil? ? UNKNOWN_REQUEST_ID : id
18
+
19
+ def patch_server_send_error!
20
+ return if server_send_error_patched?
21
+
22
+ FastMcp::Server.class_eval do
23
+ alias_method :send_error_without_json_rpc_compat, :send_error
24
+
25
+ def send_error(code, message, id = nil)
26
+ send_error_without_json_rpc_compat(code, message, OmnifocusMcp::JsonRpcCompat.normalize_id(id))
27
+ end
28
+ end
29
+ end
30
+ private_class_method :patch_server_send_error!
31
+
32
+ def patch_stdio_transport!
33
+ return if stdio_transport_patched?
34
+
35
+ FastMcp::Transports::StdioTransport.class_eval do
36
+ alias_method :start_without_json_rpc_compat, :start
37
+
38
+ def start
39
+ @logger.info("Starting STDIO transport")
40
+ @running = true
41
+
42
+ while @running && (line = $stdin.gets)
43
+ stripped = line.strip
44
+ next if stripped.empty?
45
+
46
+ begin
47
+ process_message(stripped)
48
+ rescue StandardError => e
49
+ @logger.error("Error processing message: #{e.message}")
50
+ @logger.error(e.backtrace.join("\n"))
51
+ send_error(-32_000, "Internal error: #{e.message}")
52
+ end
53
+ end
54
+ end
55
+
56
+ alias_method :send_error_without_json_rpc_compat, :send_error
57
+
58
+ def send_error(code, message, id = nil)
59
+ send_error_without_json_rpc_compat(code, message, OmnifocusMcp::JsonRpcCompat.normalize_id(id))
60
+ end
61
+ end
62
+ end
63
+ private_class_method :patch_stdio_transport!
64
+
65
+ def server_send_error_patched?
66
+ FastMcp::Server.private_method_defined?(:send_error_without_json_rpc_compat)
67
+ end
68
+ private_class_method :server_send_error_patched?
69
+
70
+ def stdio_transport_patched?
71
+ FastMcp::Transports::StdioTransport.private_method_defined?(:start_without_json_rpc_compat)
72
+ end
73
+ private_class_method :stdio_transport_patched?
74
+ end
75
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module OmnifocusMcp
6
+ module_function
7
+
8
+ # Application-wide logger writing to the current `$stderr`.
9
+ #
10
+ # Rebuilds when `$stderr` changes so test helpers that redirect stderr
11
+ # (RSpec's `output.to_stderr`, silencing helpers) still capture log lines.
12
+ def logger
13
+ reset_logger_if_stderr_changed
14
+ @logger ||= build_logger
15
+ end
16
+
17
+ def reset_logger!
18
+ @logger = nil
19
+ @logger_stderr = nil
20
+ end
21
+
22
+ def build_logger
23
+ Logger.new($stderr, progname: "omnifocus_mcp").tap { |log| log.level = Logger::WARN }
24
+ end
25
+ private :build_logger
26
+
27
+ def reset_logger_if_stderr_changed
28
+ return if @logger_stderr.equal?($stderr)
29
+
30
+ @logger = nil
31
+ @logger_stderr = $stderr
32
+ end
33
+ private :reset_logger_if_stderr_changed
34
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fast_mcp"
4
+
5
+ module OmnifocusMcp
6
+ module Mcp
7
+ extend self
8
+
9
+ INSTRUCTIONS = <<~INSTRUCTIONS
10
+ OmniFocus MCP server for macOS task management.
11
+
12
+ TOOL GUIDANCE:
13
+ - Use query_omnifocus for targeted lookups; the "fields" parameter requests only needed fields
14
+ - Use "summary: true" for quick counts without full data
15
+ - For batch operations, prefer batch_add_items/batch_remove_items over repeated single calls
16
+
17
+ RESOURCES:
18
+ - omnifocus://inbox - current inbox items
19
+ - omnifocus://today - today's agenda (due, planned, overdue)
20
+ - omnifocus://flagged - all flagged items
21
+ - omnifocus://stats - quick database statistics
22
+ - omnifocus://project/{name} - tasks in a specific project
23
+ - omnifocus://perspective/{name} - items in a named perspective
24
+
25
+ QUERY FILTER TIPS:
26
+ - Tags filter is case-sensitive and exact match
27
+ - projectName filter is case-insensitive partial match
28
+ - Status values for tasks: Next, Available, Blocked, DueSoon, Overdue
29
+ - Status values for projects: Active, OnHold, Done, Dropped
30
+ - Combine filters with AND logic; within arrays, OR logic applies
31
+ INSTRUCTIONS
32
+
33
+ def server_name = "OmniFocus MCP"
34
+
35
+ def server_version = VERSION
36
+
37
+ def build_server(logger: FastMcp::Logger.new)
38
+ FastMcp::Server.new(name: server_name, version: server_version, logger: logger).tap do |server|
39
+ register_tools(server)
40
+ register_resources(server)
41
+ end
42
+ end
43
+
44
+ def start = build_server.start
45
+
46
+ private
47
+
48
+ def register_tools(server)
49
+ server.register_tools(
50
+ Tools::Definitions::AddOmniFocusTaskTool,
51
+ Tools::Definitions::AddProjectTool,
52
+ Tools::Definitions::RemoveItemTool,
53
+ Tools::Definitions::EditItemTool,
54
+ Tools::Definitions::BatchAddItemsTool,
55
+ Tools::Definitions::BatchRemoveItemsTool,
56
+ Tools::Definitions::QueryOmnifocusTool,
57
+ Tools::Definitions::ListPerspectivesTool,
58
+ Tools::Definitions::GetPerspectiveViewTool,
59
+ Tools::Definitions::ListTagsTool
60
+ )
61
+ end
62
+
63
+ def register_resources(server)
64
+ server.register_resources(
65
+ Resources::InboxResource,
66
+ Resources::TodayResource,
67
+ Resources::FlaggedResource,
68
+ Resources::StatsResource,
69
+ Resources::ProjectResource,
70
+ Resources::PerspectiveResource
71
+ )
72
+ end
73
+ end
74
+ end
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../result"
6
+
7
+ module OmnifocusMcp
8
+ module Parsers
9
+ # Parses the `{"success": true|false, ...}` JSON envelope the AppleScript primitives return on stdout.
10
+ # The block runs only on a +success: true+ envelope; it receives the parsed Hash and must itself
11
+ # return a {OmnifocusMcp::Result} (typically wrapping a typed +Data+ payload).
12
+ module AppleScriptEnvelope
13
+ # Max characters of raw stdout to include in a JSON-parse error message.
14
+ # Keeps logs/error reporters from being swamped by multi-page AppleScript dumps.
15
+ STDOUT_PREVIEW_LIMIT = 200
16
+
17
+ class << self
18
+ def parse(stdout:, default_error:, &)
19
+ parse_json(stdout).and_then { |hash| from_envelope(hash, default_error: default_error) }
20
+ .and_then(&)
21
+ end
22
+
23
+ private
24
+
25
+ def parse_json(stdout)
26
+ Result.ok(JSON.parse(stdout))
27
+ rescue JSON::ParserError => e
28
+ preview = stdout.to_s[0, STDOUT_PREVIEW_LIMIT]
29
+ Result.error("Failed to parse AppleScript result (#{e.message}): #{preview}")
30
+ end
31
+
32
+ def from_envelope(parsed, default_error:)
33
+ return Result.error(default_error) unless parsed.is_a?(Hash)
34
+
35
+ if parsed["success"]
36
+ Result.ok(parsed)
37
+ else
38
+ Result.error(parsed["error"] || default_error)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Parsers
5
+ end
6
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fast_mcp"
4
+ require "json"
5
+
6
+ module OmnifocusMcp
7
+ module Resources
8
+ # Shared superclass for OmniFocus MCP resources.
9
+ #
10
+ # All resources serialize a Ruby object as pretty-printed JSON. Subclasses
11
+ # implement `#payload` to produce that Ruby object.
12
+ #
13
+ # MCP resources return:
14
+ # { contents: [{ uri, mimeType: "application/json", text: pretty_json }] }
15
+ # `FastMcp::Server#handle_resources_read` wraps the `#content` string with
16
+ # the equivalent `contents` envelope (see fast-mcp's resource.rb).
17
+ class Base < FastMcp::Resource
18
+ mime_type "application/json"
19
+
20
+ def content = JSON.pretty_generate(camelize_keys(safe_payload))
21
+
22
+ # Subclasses must implement.
23
+ # @return [Object] anything `JSON.pretty_generate` can serialize
24
+ def payload = raise NotImplementedError, "#{self.class} must implement #payload"
25
+
26
+ private
27
+
28
+ def safe_payload
29
+ payload
30
+ rescue StandardError, NotImplementedError => e
31
+ scope = self.class.resource_name || self.class.name
32
+ OmnifocusMcp.logger.warn("[resource:#{scope}] #{e.message}")
33
+ { error: e.message }
34
+ end
35
+
36
+ def snake_case_keys(obj)
37
+ case obj
38
+ when Hash
39
+ obj.each_with_object({}) do |(key, value), out|
40
+ out[snake_case_key(key)] = snake_case_keys(value)
41
+ end
42
+ when Array
43
+ obj.map { |item| snake_case_keys(item) }
44
+ else
45
+ obj
46
+ end
47
+ end
48
+
49
+ def camelize_keys(obj)
50
+ case obj
51
+ when Hash
52
+ obj.each_with_object({}) do |(key, value), out|
53
+ out[camel_case_key(key)] = camelize_keys(value)
54
+ end
55
+ when Array
56
+ obj.map { |item| camelize_keys(item) }
57
+ else
58
+ obj
59
+ end
60
+ end
61
+
62
+ def snake_case_key(key)
63
+ return key unless key.is_a?(Symbol) || key.is_a?(String)
64
+
65
+ key.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
66
+ end
67
+
68
+ def camel_case_key(key)
69
+ return key unless key.is_a?(Symbol) || key.is_a?(String)
70
+
71
+ parts = key.to_s.split("_")
72
+ ([parts.first] + parts.drop(1).map(&:capitalize)).join
73
+ end
74
+
75
+ public
76
+
77
+ # Swallows failures into an empty array rather than
78
+ # surfacing an `{ error: ... }` envelope. Used by aggregated resources (e.g.
79
+ # `TodayResource`) that bundle multiple queries and prefer a missing section to
80
+ # an inline error.
81
+ def items_or_empty(result)
82
+ result.map { |match| snake_case_keys(match.items || []) }
83
+ .ok_or([])
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../tools/operations/query_omnifocus"
5
+
6
+ module OmnifocusMcp
7
+ module Resources
8
+ # All flagged OmniFocus items.
9
+ class FlaggedResource < Base
10
+ uri "omnifocus://flagged"
11
+ resource_name "flagged"
12
+ description "All flagged OmniFocus items"
13
+
14
+ FIELDS = %w[id name dueDate projectName tagNames taskStatus].freeze
15
+
16
+ def payload
17
+ OmnifocusMcp.logger.warn("[resource:flagged] Reading flagged items")
18
+
19
+ params = Tools::Params::QueryOmnifocusParams.from_hash(
20
+ entity: "tasks",
21
+ filters: { flagged: true },
22
+ fields: FIELDS
23
+ )
24
+ Tools::Operations::QueryOmnifocus.call(params).fold(
25
+ on_ok: ->(match) { snake_case_keys(match.items || []) },
26
+ on_error: ->(err) { { error: err } }
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../tools/operations/query_omnifocus"
5
+
6
+ module OmnifocusMcp
7
+ module Resources
8
+ # Current OmniFocus inbox items.
9
+ class InboxResource < Base
10
+ uri "omnifocus://inbox"
11
+ resource_name "inbox"
12
+ description "Current OmniFocus inbox items"
13
+
14
+ FIELDS = %w[id name flagged dueDate deferDate tagNames taskStatus note].freeze
15
+
16
+ def payload
17
+ OmnifocusMcp.logger.warn("[resource:inbox] Reading inbox items")
18
+
19
+ params = Tools::Params::QueryOmnifocusParams.from_hash(
20
+ entity: "tasks",
21
+ filters: { inbox: true },
22
+ fields: FIELDS
23
+ )
24
+ Tools::Operations::QueryOmnifocus.call(params).fold(
25
+ on_ok: ->(match) { snake_case_keys(match.items || []) },
26
+ on_error: ->(err) { { error: err } }
27
+ )
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../tools/operations/get_perspective_view"
5
+
6
+ module OmnifocusMcp
7
+ module Resources
8
+ # Items visible in a named OmniFocus perspective.
9
+ #
10
+ # `#content` (via `#payload`) is the sole entry point.
11
+ class PerspectiveResource < Base
12
+ uri "omnifocus://perspective/{name}"
13
+ resource_name "perspective"
14
+ description "Items visible in a named OmniFocus perspective"
15
+
16
+ def payload
17
+ name = params[:name].to_s
18
+ OmnifocusMcp.logger.warn("[resource:perspective] Reading perspective: #{name}")
19
+
20
+ params = Tools::Params::GetPerspectiveViewParams.from_hash(perspective_name: name)
21
+ Tools::Operations::GetPerspectiveView.call(params).fold(
22
+ on_ok: ->(items) { snake_case_keys(items || []) },
23
+ on_error: ->(err) { { error: err } }
24
+ )
25
+ end
26
+ end
27
+ end
28
+ end