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.
- checksums.yaml +7 -0
- data/AGENTS.md +15 -0
- data/CHANGELOG.md +7 -0
- data/CODE_OF_CONDUCT.md +16 -0
- data/LICENSE.txt +21 -0
- data/README.md +147 -0
- data/Rakefile +12 -0
- data/bin/omnifocus-mcp +7 -0
- data/lib/omnifocus_mcp/config.rb +18 -0
- data/lib/omnifocus_mcp/infrastructure/.keep +1 -0
- data/lib/omnifocus_mcp/infrastructure/apple_script.rb +263 -0
- data/lib/omnifocus_mcp/infrastructure/apple_script_date_builder.rb +65 -0
- data/lib/omnifocus_mcp/infrastructure/js_embed.rb +39 -0
- data/lib/omnifocus_mcp/infrastructure/script_runner.rb +254 -0
- data/lib/omnifocus_mcp/infrastructure.rb +6 -0
- data/lib/omnifocus_mcp/json_rpc_compat.rb +75 -0
- data/lib/omnifocus_mcp/logger.rb +34 -0
- data/lib/omnifocus_mcp/mcp.rb +74 -0
- data/lib/omnifocus_mcp/parsers/.keep +1 -0
- data/lib/omnifocus_mcp/parsers/apple_script_envelope.rb +44 -0
- data/lib/omnifocus_mcp/parsers.rb +6 -0
- data/lib/omnifocus_mcp/resources/base.rb +87 -0
- data/lib/omnifocus_mcp/resources/flagged_resource.rb +31 -0
- data/lib/omnifocus_mcp/resources/inbox_resource.rb +31 -0
- data/lib/omnifocus_mcp/resources/perspective_resource.rb +28 -0
- data/lib/omnifocus_mcp/resources/project_resource.rb +37 -0
- data/lib/omnifocus_mcp/resources/stats_resource.rb +22 -0
- data/lib/omnifocus_mcp/resources/today_resource.rb +37 -0
- data/lib/omnifocus_mcp/result.rb +108 -0
- data/lib/omnifocus_mcp/tools/batch_report.rb +9 -0
- data/lib/omnifocus_mcp/tools/database_stats.rb +184 -0
- data/lib/omnifocus_mcp/tools/definitions/add_omnifocus_task_tool.rb +61 -0
- data/lib/omnifocus_mcp/tools/definitions/add_project_tool.rb +54 -0
- data/lib/omnifocus_mcp/tools/definitions/batch_add_items_tool.rb +105 -0
- data/lib/omnifocus_mcp/tools/definitions/batch_remove_items_tool.rb +68 -0
- data/lib/omnifocus_mcp/tools/definitions/date_formatter.rb +45 -0
- data/lib/omnifocus_mcp/tools/definitions/edit_item_tool.rb +87 -0
- data/lib/omnifocus_mcp/tools/definitions/get_perspective_view_tool.rb +57 -0
- data/lib/omnifocus_mcp/tools/definitions/key_normalizer.rb +30 -0
- data/lib/omnifocus_mcp/tools/definitions/list_perspectives_tool.rb +47 -0
- data/lib/omnifocus_mcp/tools/definitions/list_tags_tool.rb +42 -0
- data/lib/omnifocus_mcp/tools/definitions/mcp_envelope.rb +31 -0
- data/lib/omnifocus_mcp/tools/definitions/operation_factory.rb +33 -0
- data/lib/omnifocus_mcp/tools/definitions/query_omnifocus_tool.rb +187 -0
- data/lib/omnifocus_mcp/tools/definitions/remove_item_tool.rb +55 -0
- data/lib/omnifocus_mcp/tools/generators/.keep +1 -0
- data/lib/omnifocus_mcp/tools/generators/add_omnifocus_task.rb +348 -0
- data/lib/omnifocus_mcp/tools/generators/add_project.rb +141 -0
- data/lib/omnifocus_mcp/tools/generators/database_stats.rb +16 -0
- data/lib/omnifocus_mcp/tools/generators/edit_item.rb +455 -0
- data/lib/omnifocus_mcp/tools/generators/list_perspectives.rb +13 -0
- data/lib/omnifocus_mcp/tools/generators/list_tags.rb +13 -0
- data/lib/omnifocus_mcp/tools/generators/perspective_view.rb +17 -0
- data/lib/omnifocus_mcp/tools/generators/query_omnifocus.rb +571 -0
- data/lib/omnifocus_mcp/tools/generators/query_omnifocus_debug.rb +169 -0
- data/lib/omnifocus_mcp/tools/generators/remove_item.rb +61 -0
- data/lib/omnifocus_mcp/tools/generators.rb +8 -0
- data/lib/omnifocus_mcp/tools/messages/add_omnifocus_task.rb +53 -0
- data/lib/omnifocus_mcp/tools/messages/add_project.rb +28 -0
- data/lib/omnifocus_mcp/tools/messages/batch_remove_items.rb +13 -0
- data/lib/omnifocus_mcp/tools/messages/edit_item.rb +39 -0
- data/lib/omnifocus_mcp/tools/messages/list_tools.rb +15 -0
- data/lib/omnifocus_mcp/tools/messages/remove_item.rb +42 -0
- data/lib/omnifocus_mcp/tools/messages.rb +8 -0
- data/lib/omnifocus_mcp/tools/operations/add_omnifocus_task.rb +74 -0
- data/lib/omnifocus_mcp/tools/operations/add_project.rb +75 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/batch_item.rb +38 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/bulk_executor.rb +94 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/cycle_detector.rb +74 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/param_builder.rb +47 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items/planner.rb +111 -0
- data/lib/omnifocus_mcp/tools/operations/batch_add_items.rb +149 -0
- data/lib/omnifocus_mcp/tools/operations/batch_remove_items.rb +49 -0
- data/lib/omnifocus_mcp/tools/operations/database_stats.rb +52 -0
- data/lib/omnifocus_mcp/tools/operations/edit_item.rb +79 -0
- data/lib/omnifocus_mcp/tools/operations/get_perspective_view.rb +112 -0
- data/lib/omnifocus_mcp/tools/operations/list_perspectives.rb +85 -0
- data/lib/omnifocus_mcp/tools/operations/list_tags.rb +80 -0
- data/lib/omnifocus_mcp/tools/operations/query_omnifocus.rb +74 -0
- data/lib/omnifocus_mcp/tools/operations/query_omnifocus_debug.rb +63 -0
- data/lib/omnifocus_mcp/tools/operations/remove_item.rb +75 -0
- data/lib/omnifocus_mcp/tools/operations.rb +8 -0
- data/lib/omnifocus_mcp/tools/params/mcp_boundary.rb +41 -0
- data/lib/omnifocus_mcp/tools/params.rb +106 -0
- data/lib/omnifocus_mcp/tools/presenters/batch_report.rb +55 -0
- data/lib/omnifocus_mcp/tools/presenters/list_perspectives.rb +33 -0
- data/lib/omnifocus_mcp/tools/presenters/list_tags.rb +49 -0
- data/lib/omnifocus_mcp/tools/presenters/perspective_view.rb +81 -0
- data/lib/omnifocus_mcp/tools/presenters/query_reply.rb +52 -0
- data/lib/omnifocus_mcp/tools/presenters/query_results.rb +183 -0
- data/lib/omnifocus_mcp/tools/presenters.rb +8 -0
- data/lib/omnifocus_mcp/tools/query_omnifocus_formatter.rb +9 -0
- data/lib/omnifocus_mcp/tools/query_statuses.rb +22 -0
- data/lib/omnifocus_mcp/utils/apple_script.rb +9 -0
- data/lib/omnifocus_mcp/utils/apple_script_envelope.rb +9 -0
- data/lib/omnifocus_mcp/utils/apple_script_helpers.rb +9 -0
- data/lib/omnifocus_mcp/utils/blank.rb +26 -0
- data/lib/omnifocus_mcp/utils/date_filter.rb +76 -0
- data/lib/omnifocus_mcp/utils/date_formatting.rb +9 -0
- data/lib/omnifocus_mcp/utils/iso_date.rb +27 -0
- data/lib/omnifocus_mcp/utils/omnifocus_scripts/getPerspectiveView.js +472 -0
- data/lib/omnifocus_mcp/utils/omnifocus_scripts/listPerspectives.js +59 -0
- data/lib/omnifocus_mcp/utils/omnifocus_scripts/listTags.js +58 -0
- data/lib/omnifocus_mcp/utils/omnifocus_scripts/omnifocusDump.js +223 -0
- data/lib/omnifocus_mcp/utils/script_execution.rb +9 -0
- data/lib/omnifocus_mcp/version.rb +5 -0
- data/lib/omnifocus_mcp.rb +102 -0
- 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,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,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
|