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,37 @@
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
+ # Tasks in a specific OmniFocus project, addressed by name.
9
+ #
10
+ # `#content` (via `#payload`) is the sole entry point.
11
+ class ProjectResource < Base
12
+ uri "omnifocus://project/{name}"
13
+ resource_name "project"
14
+ description "Tasks in a specific OmniFocus project"
15
+
16
+ FIELDS = %w[
17
+ id name flagged dueDate deferDate taskStatus
18
+ tagNames parentId note estimatedMinutes
19
+ ].freeze
20
+
21
+ def payload
22
+ name = params[:name].to_s
23
+ OmnifocusMcp.logger.warn("[resource:project] Reading project: #{name}")
24
+
25
+ params = Tools::Params::QueryOmnifocusParams.from_hash(
26
+ entity: "tasks",
27
+ filters: { project_name: name },
28
+ fields: FIELDS
29
+ )
30
+ Tools::Operations::QueryOmnifocus.call(params).fold(
31
+ on_ok: ->(match) { snake_case_keys(match.items || []) },
32
+ on_error: ->(err) { { error: err } }
33
+ )
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base"
4
+ require_relative "../tools/operations/database_stats"
5
+
6
+ module OmnifocusMcp
7
+ module Resources
8
+ # Quick OmniFocus database statistics overview.
9
+ class StatsResource < Base
10
+ uri "omnifocus://stats"
11
+ resource_name "stats"
12
+ description "Quick OmniFocus database statistics overview"
13
+
14
+ def payload
15
+ Tools::Operations::DatabaseStats.get_database_stats.fold(
16
+ on_ok: ->(stats) { snake_case_keys(stats || {}) },
17
+ on_error: ->(err) { { error: err } }
18
+ )
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,37 @@
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
+ class TodayResource < Base
9
+ uri "omnifocus://today"
10
+ resource_name "today"
11
+ description "Today's agenda — tasks due today, planned for today, and overdue items"
12
+
13
+ DUE_FIELDS = %w[id name flagged dueDate projectName tagNames taskStatus].freeze
14
+ PLANNED_FIELDS = %w[id name flagged plannedDate projectName tagNames taskStatus].freeze
15
+ OVERDUE_FIELDS = DUE_FIELDS
16
+
17
+ def payload
18
+ OmnifocusMcp.logger.warn("[resource:today] Reading today's agenda")
19
+
20
+ {
21
+ due_today: items_or_empty(query(filters: { due_on: 0 }, fields: DUE_FIELDS)),
22
+ planned_today: items_or_empty(query(filters: { planned_on: 0 }, fields: PLANNED_FIELDS)),
23
+ overdue: items_or_empty(query(filters: { status: ["Overdue"] }, fields: OVERDUE_FIELDS))
24
+ }
25
+ end
26
+
27
+ private
28
+
29
+ def query(filters:, fields:)
30
+ params = Tools::Params::QueryOmnifocusParams.from_hash(
31
+ entity: "tasks", filters: filters, fields: fields
32
+ )
33
+ Tools::Operations::QueryOmnifocus.call(params)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ # Success/disjoint-error ADT. {#and_then} does not rescue: the block must return another Result and
5
+ # should not raise for domain failures (use {#map} or return Result.error).
6
+ # Programmer bugs and invariant violations should use +raise+, not Result.error.
7
+ Result = Data.define(:value, :error_message, :creation_location) do
8
+ def self.ok(value)
9
+ new(value: value, error_message: nil, creation_location: caller_locations(1, 1).first)
10
+ end
11
+
12
+ # @param message [Object] user-facing String or other user-facing error payload.
13
+ def self.error(message)
14
+ new(value: nil, error_message: message, creation_location: caller_locations(1, 1).first)
15
+ end
16
+
17
+ # Pair two results; first error wins. Values become a two-element Array on success.
18
+ def self.zip(left, right)
19
+ return left if left.error?
20
+ return right if right.error?
21
+
22
+ Result.ok([left.ok, right.ok])
23
+ end
24
+
25
+ # Sequence a +Hash+ or +Array+ of Results into a single Result. Fail-fast: the first error in
26
+ # iteration order wins (insertion order for Hashes). On success the wrapped value mirrors the
27
+ # input container's shape: array-in → array-of-values-out, hash-in → hash-of-values-out.
28
+ def self.all(results)
29
+ case results
30
+ when Array
31
+ first_error = results.find(&:error?)
32
+ first_error || Result.ok(results.map(&:ok))
33
+ when Hash
34
+ first_error = results.each_value.find(&:error?)
35
+ first_error || Result.ok(results.transform_values(&:ok))
36
+ else
37
+ raise ArgumentError, "Result.all expects an Array or Hash, got #{results.class}"
38
+ end
39
+ end
40
+
41
+ def ok? = !value.nil?
42
+
43
+ def error? = !error_message.nil?
44
+
45
+ def ok
46
+ raise "Cannot get value from error result: #{error_message}, #{creation_location}" if error?
47
+
48
+ value
49
+ end
50
+
51
+ def ok_or(default_value) = ok? ? value : default_value
52
+
53
+ def error
54
+ raise "Cannot get error from ok result" if ok?
55
+
56
+ error_message
57
+ end
58
+
59
+ def map
60
+ return self if error?
61
+
62
+ Result.ok(yield(ok))
63
+ rescue StandardError => e
64
+ Result.error(e.message)
65
+ end
66
+
67
+ def and_then
68
+ return self if error?
69
+
70
+ yield(ok)
71
+ end
72
+
73
+ def or_else
74
+ return self if ok?
75
+
76
+ yield(error)
77
+ end
78
+
79
+ # Collapses the +if ok? ... else ... end+ shape into a single expression. The chosen branch is
80
+ # called with the wrapped value (or error message) and its return value is passed through verbatim;
81
+ # this is a terminator, not a chainable Result-returning combinator.
82
+ def fold(on_ok:, on_error:) = ok? ? on_ok.call(ok) : on_error.call(error)
83
+
84
+ def where = creation_location
85
+
86
+ def deconstruct = [value, error_message]
87
+
88
+ def deconstruct_keys(keys)
89
+ if keys.nil?
90
+ return error? ? { error_message: error_message } : { value: value }
91
+ end
92
+
93
+ keys.filter_map do |key|
94
+ val = public_send(key)
95
+ [key, val] unless val.nil?
96
+ end.to_h
97
+ end
98
+
99
+ private
100
+
101
+ def initialize(value:, error_message:, creation_location:)
102
+ raise ArgumentError, "Cannot have both value and error" unless value.nil? || error_message.nil?
103
+ raise ArgumentError, "Must provide either value or error" if value.nil? && error_message.nil?
104
+
105
+ super
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "presenters/batch_report"
4
+
5
+ module OmnifocusMcp
6
+ module Tools
7
+ BatchReport = Presenters::BatchReport
8
+ end
9
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../result"
4
+ require_relative "../infrastructure/js_embed"
5
+ require_relative "../infrastructure/script_runner"
6
+
7
+ module OmnifocusMcp
8
+ module Tools
9
+ # Lightweight database overview helpers that don't require pulling the
10
+ # full OmniFocus dataset.
11
+ #
12
+ # Provides:
13
+ # * {.get_database_stats} — counts + last-modified timestamp
14
+ # * {.get_changes_since} — incremental change feed since a timestamp
15
+ # rubocop:disable Metrics/ModuleLength
16
+ module DatabaseStats
17
+ # Lightweight database statistics: counts + last-modified timestamp.
18
+ #
19
+ # @return [OmnifocusMcp::Result] +ok+ carries the stats Hash; +error+ carries a user-facing message.
20
+ class << self
21
+ def get_database_stats
22
+ require_relative "operations/database_stats"
23
+
24
+ Operations::DatabaseStats.get_database_stats
25
+ end
26
+
27
+ # Incremental change feed since `since` (a Time, DateTime, or ISO string).
28
+ #
29
+ # @return [OmnifocusMcp::Result] +ok+ carries the changes Hash; +error+ carries a user-facing message.
30
+ def get_changes_since(since)
31
+ require_relative "operations/database_stats"
32
+
33
+ Operations::DatabaseStats.get_changes_since(since)
34
+ end
35
+
36
+ STATS_SCRIPT = <<~JS
37
+ (() => {
38
+ try {
39
+ const allTasks = flattenedTasks;
40
+ const activeTasks = allTasks.filter(task =>
41
+ task.taskStatus !== Task.Status.Completed &&
42
+ task.taskStatus !== Task.Status.Dropped
43
+ );
44
+
45
+ const allProjects = flattenedProjects;
46
+ const activeProjects = allProjects.filter(project =>
47
+ project.status === Project.Status.Active
48
+ );
49
+
50
+ const overdueCount = activeTasks.filter(task =>
51
+ task.taskStatus === Task.Status.Overdue
52
+ ).length;
53
+
54
+ const nextActionCount = activeTasks.filter(task =>
55
+ task.taskStatus === Task.Status.Next
56
+ ).length;
57
+
58
+ const flaggedCount = activeTasks.filter(task => task.flagged).length;
59
+ const inboxCount = activeTasks.filter(task => task.inInbox).length;
60
+
61
+ let lastModified = new Date(0);
62
+ allTasks.forEach(task => {
63
+ if (task.modificationDate && task.modificationDate > lastModified) {
64
+ lastModified = task.modificationDate;
65
+ }
66
+ });
67
+
68
+ return JSON.stringify({
69
+ taskCount: allTasks.length,
70
+ activeTaskCount: activeTasks.length,
71
+ projectCount: allProjects.length,
72
+ activeProjectCount: activeProjects.length,
73
+ folderCount: flattenedFolders.length,
74
+ tagCount: flattenedTags.filter(tag => tag.active).length,
75
+ overdueCount: overdueCount,
76
+ nextActionCount: nextActionCount,
77
+ flaggedCount: flaggedCount,
78
+ inboxCount: inboxCount,
79
+ lastModified: lastModified.toISOString()
80
+ });
81
+
82
+ } catch (error) {
83
+ return JSON.stringify({
84
+ error: "Failed to get database stats: " + error.toString()
85
+ });
86
+ }
87
+ })();
88
+ JS
89
+
90
+ # rubocop:disable Metrics/MethodLength
91
+ def changes_script(since_iso)
92
+ escaped_since = Infrastructure::JsEmbed.double_quoted_string(since_iso)
93
+
94
+ <<~JS
95
+ (() => {
96
+ try {
97
+ const sinceDate = new Date("#{escaped_since}");
98
+
99
+ const allTasks = flattenedTasks;
100
+
101
+ const newTasks = allTasks.filter(task =>
102
+ task.creationDate && task.creationDate > sinceDate
103
+ ).map(task => ({
104
+ id: task.id.primaryKey,
105
+ name: task.name,
106
+ creationDate: task.creationDate.toISOString()
107
+ }));
108
+
109
+ const updatedTasks = allTasks.filter(task =>
110
+ task.modificationDate &&
111
+ task.modificationDate > sinceDate &&
112
+ task.creationDate &&
113
+ task.creationDate <= sinceDate
114
+ ).map(task => ({
115
+ id: task.id.primaryKey,
116
+ name: task.name,
117
+ modificationDate: task.modificationDate.toISOString()
118
+ }));
119
+
120
+ const completedTasks = allTasks.filter(task =>
121
+ task.completionDate &&
122
+ task.completionDate > sinceDate
123
+ ).map(task => ({
124
+ id: task.id.primaryKey,
125
+ name: task.name,
126
+ completionDate: task.completionDate.toISOString()
127
+ }));
128
+
129
+ const allProjects = flattenedProjects;
130
+
131
+ const newProjects = allProjects.filter(project =>
132
+ project.creationDate && project.creationDate > sinceDate
133
+ ).map(project => ({
134
+ id: project.id.primaryKey,
135
+ name: project.name,
136
+ creationDate: project.creationDate.toISOString()
137
+ }));
138
+
139
+ const updatedProjects = allProjects.filter(project =>
140
+ project.modificationDate &&
141
+ project.modificationDate > sinceDate &&
142
+ project.creationDate &&
143
+ project.creationDate <= sinceDate
144
+ ).map(project => ({
145
+ id: project.id.primaryKey,
146
+ name: project.name,
147
+ modificationDate: project.modificationDate.toISOString()
148
+ }));
149
+
150
+ return JSON.stringify({
151
+ newTasks: newTasks,
152
+ updatedTasks: updatedTasks,
153
+ completedTasks: completedTasks,
154
+ newProjects: newProjects,
155
+ updatedProjects: updatedProjects
156
+ });
157
+
158
+ } catch (error) {
159
+ return JSON.stringify({
160
+ error: "Failed to get changes: " + error.toString()
161
+ });
162
+ }
163
+ })();
164
+ JS
165
+ end
166
+ # rubocop:enable Metrics/MethodLength
167
+
168
+ private
169
+
170
+ # Collapse a {Infrastructure::ScriptRunner} {Result} into a {Result} over the parsed Hash payload.
171
+ def script_payload_result(execution)
172
+ execution.and_then do |payload|
173
+ if payload.is_a?(Hash) && payload["error"]
174
+ Result.error(payload["error"])
175
+ else
176
+ Result.ok(payload)
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
182
+ # rubocop:enable Metrics/ModuleLength
183
+ end
184
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fast_mcp"
4
+
5
+ require_relative "mcp_envelope"
6
+ require_relative "operation_factory"
7
+ require_relative "../messages/add_omnifocus_task"
8
+ require_relative "../operations/add_omnifocus_task"
9
+ require_relative "../params"
10
+
11
+ module OmnifocusMcp
12
+ module Tools
13
+ module Definitions
14
+ # `FastMcp::Tool` for `add_omnifocus_task`.
15
+ class AddOmniFocusTaskTool < FastMcp::Tool
16
+ tool_name "add_omnifocus_task"
17
+ description "Add a new task to OmniFocus"
18
+
19
+ arguments do
20
+ required(:name).filled(:string).description("The name of the task")
21
+ optional(:note).filled(:string).description("Additional notes for the task")
22
+ optional(:dueDate).filled(:string)
23
+ .description("The due date of the task in ISO format (YYYY-MM-DD or full ISO date)")
24
+ optional(:deferDate).filled(:string)
25
+ .description("The defer date of the task in ISO format (YYYY-MM-DD or full ISO date)")
26
+ optional(:plannedDate).filled(:string).description(
27
+ "The planned date of the task in ISO format (YYYY-MM-DD or full ISO date) - " \
28
+ "indicates intention to work on this task on this date"
29
+ )
30
+ optional(:flagged).filled(:bool).description("Whether the task is flagged or not")
31
+ optional(:estimatedMinutes).filled(:integer)
32
+ .description("Estimated time to complete the task, in minutes")
33
+ optional(:tags).array(:string).description("Tags to assign to the task")
34
+ optional(:projectName).filled(:string).description(
35
+ "The name of the project to add the task to (will add to inbox if not specified)"
36
+ )
37
+ optional(:parentTaskId).filled(:string).description("ID of the parent task (preferred for accuracy)")
38
+ optional(:parentTaskName).filled(:string).description(
39
+ "Name of the parent task (used if ID not provided; matched within project or globally if no project)"
40
+ )
41
+ optional(:hierarchyLevel).filled(:integer, gteq?: 0).description(
42
+ "Explicit level indicator for ordering in batch workflows (0=root) - ignored in single add"
43
+ )
44
+ end
45
+
46
+ extend OperationFactory
47
+
48
+ default_operation_factory { Operations::AddOmniFocusTask.method(:call) }
49
+
50
+ def call(**args)
51
+ McpEnvelope.safely("creating task") do
52
+ operation.call(Params::AddTaskParams.from_mcp(args)).fold(
53
+ on_ok: ->(created) { McpEnvelope::ToolReply.success(Messages::AddOmniFocusTask.success(args, created)) },
54
+ on_error: ->(err) { McpEnvelope::ToolReply.failure(Messages::AddOmniFocusTask.failure(err)) }
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fast_mcp"
4
+
5
+ require_relative "mcp_envelope"
6
+ require_relative "operation_factory"
7
+ require_relative "../messages/add_project"
8
+ require_relative "../operations/add_project"
9
+ require_relative "../params"
10
+
11
+ module OmnifocusMcp
12
+ module Tools
13
+ module Definitions
14
+ # `FastMcp::Tool` for `add_project`.
15
+ class AddProjectTool < FastMcp::Tool
16
+ tool_name "add_project"
17
+ description "Add a new project to OmniFocus"
18
+
19
+ arguments do
20
+ required(:name).filled(:string).description("The name of the project")
21
+ optional(:note).filled(:string).description("Additional notes for the project")
22
+ optional(:dueDate).filled(:string)
23
+ .description("The due date of the project in ISO format (YYYY-MM-DD or full ISO date)")
24
+ optional(:deferDate).filled(:string)
25
+ .description(
26
+ "The defer date of the project in ISO format (YYYY-MM-DD or full ISO date)"
27
+ )
28
+ optional(:flagged).filled(:bool).description("Whether the project is flagged or not")
29
+ optional(:estimatedMinutes).filled(:integer)
30
+ .description("Estimated time to complete the project, in minutes")
31
+ optional(:tags).array(:string).description("Tags to assign to the project")
32
+ optional(:folderName).filled(:string).description(
33
+ "The name of the folder to add the project to (will add to root if not specified)"
34
+ )
35
+ optional(:sequential).filled(:bool)
36
+ .description("Whether tasks in the project should be sequential (default: false)")
37
+ end
38
+
39
+ extend OperationFactory
40
+
41
+ default_operation_factory { Operations::AddProject.method(:call) }
42
+
43
+ def call(**args)
44
+ McpEnvelope.safely("creating project") do
45
+ operation.call(Params::AddProjectParams.from_mcp(args)).fold(
46
+ on_ok: ->(_created) { McpEnvelope::ToolReply.success(Messages::AddProject.success(args)) },
47
+ on_error: ->(err) { McpEnvelope::ToolReply.failure(Messages::AddProject.failure(err)) }
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fast_mcp"
4
+
5
+ require_relative "mcp_envelope"
6
+ require_relative "operation_factory"
7
+ require_relative "../operations/batch_add_items"
8
+ require_relative "../params"
9
+ require_relative "../presenters/batch_report"
10
+
11
+ module OmnifocusMcp
12
+ module Tools
13
+ module Definitions
14
+ # `FastMcp::Tool` for `batch_add_items`.
15
+ class BatchAddItemsTool < FastMcp::Tool
16
+ tool_name "batch_add_items"
17
+ description "Add multiple tasks or projects to OmniFocus in a single operation"
18
+
19
+ # rubocop:disable Metrics/BlockLength
20
+ arguments do
21
+ required(:items).array(:hash) do
22
+ required(:type).filled(included_in?: %w[task project])
23
+ .description("Type of item to add ('task' or 'project')")
24
+ required(:name).filled(:string).description("The name of the item")
25
+ optional(:note).filled(:string).description("Additional notes for the item")
26
+ optional(:dueDate).filled(:string)
27
+ .description("The due date in ISO format (YYYY-MM-DD or full ISO date)")
28
+ optional(:deferDate).filled(:string)
29
+ .description("The defer date in ISO format (YYYY-MM-DD or full ISO date)")
30
+ optional(:plannedDate).filled(:string).description(
31
+ "The planned date in ISO format (YYYY-MM-DD or full ISO date) - tasks only"
32
+ )
33
+ optional(:flagged).filled(:bool).description("Whether the item is flagged or not")
34
+ optional(:estimatedMinutes).filled(:integer)
35
+ .description("Estimated time to complete the item, in minutes")
36
+ optional(:tags).array(:string).description("Tags to assign to the item")
37
+
38
+ optional(:projectName).filled(:string)
39
+ .description("For tasks: The name of the project to add the task to")
40
+ optional(:parentTaskId).filled(:string).description("For tasks: ID of the parent task")
41
+ optional(:parentTaskName).filled(:string).description(
42
+ "For tasks: Name of the parent task (scoped to project when provided)"
43
+ )
44
+ optional(:tempId).filled(:string).description("For tasks: Temporary ID for within-batch references")
45
+ optional(:parentTempId).filled(:string)
46
+ .description("For tasks: Reference to parent's tempId within the batch")
47
+ optional(:hierarchyLevel).filled(:integer, gteq?: 0)
48
+ .description("Optional ordering hint (0=root, 1=child, ...)")
49
+
50
+ optional(:folderName).filled(:string)
51
+ .description("For projects: The name of the folder to add the project to")
52
+ optional(:sequential).filled(:bool)
53
+ .description("For projects: Whether tasks in the project should be sequential")
54
+ end.description("Array of items (tasks or projects) to add")
55
+ optional(:createSequentially).filled(:bool).description(
56
+ "Process parents before children; when false, best-effort order will still try to resolve parents first"
57
+ )
58
+ end
59
+ # rubocop:enable Metrics/BlockLength
60
+
61
+ extend OperationFactory
62
+
63
+ default_operation_factory { Operations::BatchAddItems.method(:call) }
64
+
65
+ def call(**args)
66
+ McpEnvelope.safely("processing batch operation") do
67
+ items = Array(args[:items]).map { |item| Params::BatchAddItemParams.from_mcp(item) }
68
+ result = operation.call(items)
69
+
70
+ result.fold(
71
+ on_ok: ->(per_item) { success_reply(per_item, items) },
72
+ on_error: ->(error) { failure_reply(error) }
73
+ )
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def failure_reply(error)
80
+ OmnifocusMcp.logger.warn("[batch_add_items] failure result: #{error.inspect}")
81
+
82
+ McpEnvelope::ToolReply.failure(
83
+ Presenters::BatchReport.format_failure(
84
+ error, results: [], items: []
85
+ ) { |item_result, item| Presenters::BatchReport.add_detail(item_result, item) }
86
+ )
87
+ end
88
+
89
+ def success_reply(per_item, items)
90
+ text = Presenters::BatchReport.format_success(
91
+ past_tense: "added", failure_verb: "add", results: per_item, items: items
92
+ ) do |item_result, item|
93
+ Presenters::BatchReport.add_detail(item_result, item)
94
+ end
95
+
96
+ if Presenters::BatchReport.all_failed?(per_item)
97
+ McpEnvelope::ToolReply.failure(text)
98
+ else
99
+ McpEnvelope::ToolReply.success(text)
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end