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,68 @@
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/batch_remove_items"
8
+ require_relative "../operations/batch_remove_items"
9
+ require_relative "../params"
10
+ require_relative "../presenters/batch_report"
11
+ require_relative "../../utils/blank"
12
+
13
+ module OmnifocusMcp
14
+ module Tools
15
+ module Definitions
16
+ # `FastMcp::Tool` for `batch_remove_items`.
17
+ class BatchRemoveItemsTool < FastMcp::Tool
18
+ tool_name "batch_remove_items"
19
+ description "Remove multiple tasks or projects from OmniFocus in a single operation"
20
+
21
+ arguments do
22
+ required(:items).array(:hash) do
23
+ optional(:id).filled(:string).description("The ID of the task or project to remove")
24
+ optional(:name).filled(:string).description(
25
+ "The name of the task or project to remove (as fallback if ID not provided)"
26
+ )
27
+ required(:itemType).filled(included_in?: %w[task project])
28
+ .description("Type of item to remove ('task' or 'project')")
29
+ end.description("Array of items (tasks or projects) to remove")
30
+ end
31
+
32
+ extend OperationFactory
33
+
34
+ default_operation_factory { Operations::BatchRemoveItems.method(:call) }
35
+
36
+ def call(**args)
37
+ items = Array(args[:items]).map { |item| Params::BatchRemoveItemParams.from_mcp(item) }
38
+ if any_missing_identifier?(items)
39
+ return McpEnvelope::ToolReply.failure(Messages::BatchRemoveItems.missing_identifier).to_envelope
40
+ end
41
+
42
+ McpEnvelope.safely("processing batch removal") do
43
+ operation.call(items).fold(
44
+ on_ok: ->(results) { success_reply(results, items) },
45
+ on_error: ->(err) { McpEnvelope::ToolReply.failure(Presenters::BatchReport.format_failure(err)) }
46
+ )
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def any_missing_identifier?(items) = items.any? { |i| Utils::Blank.blank?(i.id, i.name) }
53
+
54
+ def success_reply(results, items)
55
+ text = Presenters::BatchReport.format_success(
56
+ past_tense: "removed", failure_verb: "remove", results:, items:
57
+ ) { |item_result, item| Presenters::BatchReport.remove_detail(item_result, item) }
58
+
59
+ if Presenters::BatchReport.all_failed?(results)
60
+ McpEnvelope::ToolReply.failure(text)
61
+ else
62
+ McpEnvelope::ToolReply.success(text)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+
5
+ require_relative "../../utils/blank"
6
+ require_relative "../../utils/iso_date"
7
+
8
+ module OmnifocusMcp
9
+ module Tools
10
+ module Definitions
11
+ module DateFormatter
12
+ class << self
13
+ def format_date(iso, style:)
14
+ return "" if Utils::Blank.blank?(iso)
15
+
16
+ case style
17
+ when :locale then format_parsed(iso) { |d| us_short_date(d) }
18
+ when :compact then format_parsed(iso) { |d| us_compact_date(d) }
19
+ when :iso then Utils::IsoDate.to_date_only(iso)
20
+ else raise ArgumentError, "Unknown date style: #{style.inspect}"
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def us_short_date(date)
27
+ # TODO: Fix the formatting. Make Rubocop happy
28
+ format("%d/%d/%d", date.month, date.day, date.year)
29
+ end
30
+
31
+ def us_compact_date(date)
32
+ # TODO: Fix the formatting. Make Rubocop happy
33
+ format("%d/%d", date.month, date.day)
34
+ end
35
+
36
+ def format_parsed(iso)
37
+ yield Date.parse(iso.to_s)
38
+ rescue ArgumentError
39
+ ""
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,87 @@
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/edit_item"
8
+ require_relative "../operations/edit_item"
9
+ require_relative "../params"
10
+ require_relative "../../utils/blank"
11
+
12
+ module OmnifocusMcp
13
+ module Tools
14
+ module Definitions
15
+ # `FastMcp::Tool` for `edit_item`.
16
+ class EditItemTool < FastMcp::Tool
17
+ tool_name "edit_item"
18
+ description "Edit a task or project in OmniFocus"
19
+
20
+ # rubocop:disable Metrics/BlockLength
21
+ arguments do
22
+ optional(:id).filled(:string).description("The ID of the task or project to edit")
23
+ optional(:name).filled(:string)
24
+ .description("The name of the task or project to edit (as fallback if ID not provided)")
25
+ required(:itemType).filled(included_in?: %w[task project])
26
+ .description("Type of item to edit ('task' or 'project')")
27
+
28
+ optional(:newName).filled(:string).description("New name for the item")
29
+ optional(:newNote).filled(:string).description("New note for the item")
30
+ optional(:newDueDate).maybe(:string).description(
31
+ "New due date in ISO format (YYYY-MM-DD or full ISO date); set to empty string to clear"
32
+ )
33
+ optional(:newDeferDate).maybe(:string).description(
34
+ "New defer date in ISO format (YYYY-MM-DD or full ISO date); set to empty string to clear"
35
+ )
36
+ optional(:newPlannedDate).maybe(:string).description(
37
+ "New planned date in ISO format (YYYY-MM-DD or full ISO date); set to empty string to clear (tasks only)"
38
+ )
39
+ optional(:newFlagged).filled(:bool)
40
+ .description("Set flagged status (set to false for no flag, true for flag)")
41
+ optional(:newEstimatedMinutes).filled(:integer).description("New estimated minutes")
42
+
43
+ optional(:newStatus).filled(included_in?: %w[incomplete completed dropped skipped]).description(
44
+ "New status for tasks (incomplete, completed, dropped, skipped). 'skipped' only works on " \
45
+ "repeating tasks \u2014 it completes the current occurrence to trigger the next repeat, then " \
46
+ "drops the completed instance."
47
+ )
48
+ optional(:addTags).array(:string).description("Tags to add to the task")
49
+ optional(:removeTags).array(:string).description("Tags to remove from the task")
50
+ optional(:replaceTags).array(:string).description("Tags to replace all existing tags with")
51
+ optional(:newProjectName).maybe(:string).description(
52
+ "Move this task to a different project by name or folder path (e.g. 'My Project' or " \
53
+ "'Work/My Project' to disambiguate). Pass an empty string or 'inbox' to move the task to " \
54
+ "the inbox. (tasks only)"
55
+ )
56
+
57
+ optional(:newSequential).filled(:bool).description("Whether the project should be sequential")
58
+ optional(:newFolderName).filled(:string).description("New folder to move the project to")
59
+ optional(:newProjectStatus).filled(included_in?: %w[active completed dropped onHold])
60
+ .description("New status for projects")
61
+ end
62
+ # rubocop:enable Metrics/BlockLength
63
+
64
+ extend OperationFactory
65
+
66
+ default_operation_factory { Operations::EditItem.method(:call) }
67
+
68
+ def call(**args)
69
+ if missing_identifier?(args)
70
+ return McpEnvelope::ToolReply.failure(Messages::EditItem.missing_identifier).to_envelope
71
+ end
72
+
73
+ McpEnvelope.safely("updating #{args[:itemType]}") do
74
+ operation.call(Params::EditItemParams.from_mcp(args)).fold(
75
+ on_ok: ->(edited) { McpEnvelope::ToolReply.success(Messages::EditItem.success(args, edited)) },
76
+ on_error: ->(err) { McpEnvelope::ToolReply.failure(Messages::EditItem.failure(args, err)) }
77
+ )
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ def missing_identifier?(args) = Utils::Blank.blank?(args[:id], args[:name])
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,57 @@
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/list_tools"
8
+ require_relative "../operations/get_perspective_view"
9
+ require_relative "../params"
10
+ require_relative "../presenters/perspective_view"
11
+
12
+ module OmnifocusMcp
13
+ module Tools
14
+ module Definitions
15
+ # `FastMcp::Tool` for `get_perspective_view`.
16
+ class GetPerspectiveViewTool < FastMcp::Tool
17
+ tool_name "get_perspective_view"
18
+ description "Get the items visible in a specific OmniFocus perspective. " \
19
+ "Shows what tasks and projects are displayed when viewing that perspective"
20
+
21
+ arguments do
22
+ required(:perspectiveName).filled(:string).description(
23
+ "Name of the perspective to view (e.g., 'Inbox', 'Projects', 'Flagged', or custom perspective name)"
24
+ )
25
+ optional(:limit).filled(:integer).description("Maximum number of items to return. Default: 100")
26
+ optional(:fields).array(:string).description(
27
+ "Specific fields to include in the response. Reduces response size. Available fields: " \
28
+ "id, name, note, flagged, dueDate, deferDate, completionDate, taskStatus, projectName, " \
29
+ "tagNames, estimatedMinutes"
30
+ )
31
+ end
32
+
33
+ extend OperationFactory
34
+
35
+ default_operation_factory { Operations::GetPerspectiveView.method(:call) }
36
+
37
+ def call(**args)
38
+ McpEnvelope.safely("getting perspective view") do
39
+ params = Params::GetPerspectiveViewParams.from_mcp(args)
40
+ limit = Operations::GetPerspectiveView.normalize_limit(params.limit)
41
+ # TODO: I don't like this nested function call
42
+ operation.call(
43
+ Params::GetPerspectiveViewParams.new(
44
+ perspective_name: params.perspective_name, limit: limit, fields: params.fields
45
+ )
46
+ ).fold(
47
+ on_ok: lambda do |items|
48
+ McpEnvelope::ToolReply.success(Presenters::PerspectiveView.format(args[:perspectiveName], items, limit))
49
+ end,
50
+ on_error: ->(err) { McpEnvelope::ToolReply.failure(Messages::ListTools.perspective_view_failure(err)) }
51
+ )
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ module Definitions
6
+ module KeyNormalizer
7
+ class << self
8
+ def snake_keys(obj, deep: false)
9
+ case obj
10
+ when Hash
11
+ obj.each_with_object({}) do |(k, v), out|
12
+ out[snake_case_key(k)] = deep ? snake_keys(v, deep: true) : v
13
+ end
14
+ when Array
15
+ deep ? obj.map { |item| snake_keys(item, deep: true) } : obj
16
+ else
17
+ obj
18
+ end
19
+ end
20
+
21
+ def snake_case_key(key)
22
+ return key unless key.is_a?(Symbol) || key.is_a?(String)
23
+
24
+ key.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.to_sym
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,47 @@
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/list_tools"
8
+ require_relative "../operations/list_perspectives"
9
+ require_relative "../params"
10
+ require_relative "../presenters/list_perspectives"
11
+
12
+ module OmnifocusMcp
13
+ module Tools
14
+ module Definitions
15
+ # `FastMcp::Tool` for `list_perspectives`.
16
+ class ListPerspectivesTool < FastMcp::Tool
17
+ tool_name "list_perspectives"
18
+ description "List all available perspectives in OmniFocus, including built-in perspectives " \
19
+ "(Inbox, Projects, Tags, etc.) and custom perspectives (Pro feature)"
20
+
21
+ arguments do
22
+ optional(:includeBuiltIn).filled(:bool).description(
23
+ "Include built-in perspectives (Inbox, Projects, Tags, etc.). Default: true"
24
+ )
25
+ optional(:includeCustom).filled(:bool).description("Include custom perspectives (Pro feature). Default: true")
26
+ end
27
+
28
+ extend OperationFactory
29
+
30
+ default_operation_factory { Operations::ListPerspectives.method(:call) }
31
+
32
+ def call(**args)
33
+ McpEnvelope.safely("listing perspectives") do
34
+ params = Params::ListPerspectivesParams.from_mcp(args)
35
+
36
+ operation.call(params).fold(
37
+ on_ok: lambda { |perspectives|
38
+ McpEnvelope::ToolReply.success(Presenters::ListPerspectives.format(perspectives))
39
+ },
40
+ on_error: ->(err) { McpEnvelope::ToolReply.failure(Messages::ListTools.list_perspectives_failure(err)) }
41
+ )
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,42 @@
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/list_tools"
8
+ require_relative "../operations/list_tags"
9
+ require_relative "../params"
10
+ require_relative "../presenters/list_tags"
11
+
12
+ module OmnifocusMcp
13
+ module Tools
14
+ module Definitions
15
+ # `FastMcp::Tool` for `list_tags`.
16
+ class ListTagsTool < FastMcp::Tool
17
+ tool_name "list_tags"
18
+ description "List all tags in OmniFocus with their hierarchy. " \
19
+ "Useful for discovering available tags before creating or editing tasks."
20
+
21
+ arguments do
22
+ optional(:includeDropped).filled(:bool).description("Include dropped/inactive tags. Default: false")
23
+ end
24
+
25
+ extend OperationFactory
26
+
27
+ default_operation_factory { Operations::ListTags.method(:call) }
28
+
29
+ def call(**args)
30
+ McpEnvelope.safely("listing tags") do
31
+ params = Params::ListTagsParams.from_mcp(args)
32
+
33
+ operation.call(params).fold(
34
+ on_ok: ->(tags) { McpEnvelope::ToolReply.success(Presenters::ListTags.format(tags)) },
35
+ on_error: ->(err) { McpEnvelope::ToolReply.failure(Messages::ListTools.list_tags_failure(err)) }
36
+ )
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ module Definitions
6
+ module McpEnvelope
7
+ ToolReply = Data.define(:text, :error) do
8
+ def to_envelope = error ? McpEnvelope.text_error(text) : McpEnvelope.text_result(text)
9
+
10
+ def self.success(text) = new(text: text, error: false)
11
+ def self.failure(text) = new(text: text, error: true)
12
+ end
13
+
14
+ class << self
15
+ def text_result(text) = { content: [{ type: "text", text: text }] }
16
+
17
+ def text_error(text) = { content: [{ type: "text", text: text }], isError: true }
18
+
19
+ def safely(scope, custom_message: nil)
20
+ result = yield
21
+ result.is_a?(ToolReply) ? result.to_envelope : result
22
+ rescue StandardError => e
23
+ default = "Error #{scope}: #{e.message}"
24
+ OmnifocusMcp.logger.warn(default)
25
+ text_error(custom_message || default)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ module Definitions
6
+ module OperationFactory
7
+ def self.extended(base)
8
+ base.include InstanceMethods
9
+ end
10
+
11
+ def default_operation_factory(&factory)
12
+ @default_operation_factory = factory
13
+ end
14
+
15
+ def operation_factory
16
+ @operation_factory || @default_operation_factory
17
+ end
18
+
19
+ def operation_factory=(factory)
20
+ @operation_factory = factory
21
+ end
22
+
23
+ module InstanceMethods
24
+ private
25
+
26
+ def operation
27
+ self.class.operation_factory.call
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,187 @@
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/query_omnifocus"
8
+ require_relative "../params"
9
+ require_relative "../presenters/query_reply"
10
+ require_relative "../query_statuses"
11
+ require_relative "../../utils/date_filter"
12
+
13
+ module OmnifocusMcp
14
+ module Tools
15
+ module Definitions
16
+ # `FastMcp::Tool` for `query_omnifocus`.
17
+ class QueryOmnifocusTool < FastMcp::Tool
18
+ tool_name "query_omnifocus"
19
+ description "Efficiently query OmniFocus database with powerful filters. " \
20
+ "Get specific tasks, projects, or folders without loading the entire database. " \
21
+ "Supports filtering by project, tags, status, due dates, and more."
22
+
23
+ DATE_FILTER_FIELDS = %i[due_within deferred_until planned_within due_on defer_on planned_on].freeze
24
+
25
+ # rubocop:disable Metrics/BlockLength
26
+ arguments do
27
+ required(:entity).filled(included_in?: %w[tasks projects folders]).description(
28
+ "Type of entity to query. Choose 'tasks' for individual tasks, 'projects' for projects, " \
29
+ "or 'folders' for folder organization"
30
+ )
31
+
32
+ optional(:filters).hash do
33
+ optional(:projectId)
34
+ .filled(:string)
35
+ .description("Filter tasks by exact project ID (use when you know the specific project ID)")
36
+ optional(:projectName).filled(:string).description(
37
+ "Filter tasks by project name. CASE-INSENSITIVE PARTIAL MATCHING - 'review' matches " \
38
+ "'Weekly Review', 'Review Documents', etc. Special value: 'inbox' returns inbox tasks"
39
+ )
40
+ optional(:taskName).filled(:string).description(
41
+ "Filter tasks by task name. CASE-INSENSITIVE PARTIAL MATCHING - 'email' matches " \
42
+ "'Send email to IT', 'Confirm email' etc. Useful for fuzzy searching specific tasks across all projects"
43
+ )
44
+ optional(:folderId).filled(:string).description(
45
+ "Filter by folder ID. For tasks, returns tasks whose containing project is in this folder " \
46
+ "(or a subfolder). For projects, returns projects in this folder (or a subfolder)"
47
+ )
48
+ optional(:tags).array(:string).description(
49
+ "Filter by tag names. EXACT MATCH, CASE-SENSITIVE. OR logic - items must have at least " \
50
+ "ONE of the specified tags. Example: ['Work'] and ['work'] are different"
51
+ )
52
+ optional(:status).array(:string).description(
53
+ "Filter by status (OR logic - matches any). TASKS: #{QueryStatuses.task_list_for_schema} " \
54
+ "(next action, ready to work, waiting, due <24h, past due, completed, dropped). " \
55
+ "PROJECTS: #{QueryStatuses.project_list_for_schema}"
56
+ )
57
+ optional(:flagged).filled(:bool).description(
58
+ "Filter by flagged status. true = only flagged items, false = only unflagged items"
59
+ )
60
+ optional(:dueWithin).maybe { int? | str? }.description(
61
+ "Returns items due from TODAY through N days in future. Accepts: number (days), 'today', " \
62
+ "'tomorrow', 'this week', 'next week', or ISO date 'YYYY-MM-DD'"
63
+ )
64
+ optional(:deferredUntil).maybe { int? | str? }.description(
65
+ "Returns items CURRENTLY DEFERRED that will become available within N days. Accepts: number " \
66
+ "(days), 'today', 'tomorrow', 'this week', 'next week', or ISO date 'YYYY-MM-DD'"
67
+ )
68
+ optional(:plannedWithin).maybe { int? | str? }.description(
69
+ "Returns tasks planned from TODAY through N days in future. Accepts: number (days), 'today', " \
70
+ "'tomorrow', 'this week', 'next week', or ISO date 'YYYY-MM-DD'"
71
+ )
72
+ optional(:hasNote).filled(:bool).description(
73
+ "Filter by note presence. true = items with non-empty notes (whitespace ignored), " \
74
+ "false = items with no notes or only whitespace"
75
+ )
76
+ optional(:inbox).filled(:bool).description(
77
+ "Filter tasks by inbox status. true = only inbox tasks (no project), false = only tasks in a project"
78
+ )
79
+ optional(:dueOn).maybe { int? | str? }.description(
80
+ "Returns items due on exactly this day. Accepts: number (0 = today, 1 = tomorrow), 'today', " \
81
+ "'tomorrow', 'this week', 'next week', or ISO date 'YYYY-MM-DD'"
82
+ )
83
+ optional(:deferOn).maybe { int? | str? }.description(
84
+ "Returns items with defer date on exactly this day. Accepts: number (0 = today, 1 = tomorrow), " \
85
+ "'today', 'tomorrow', 'this week', 'next week', or ISO date 'YYYY-MM-DD'"
86
+ )
87
+ optional(:plannedOn).maybe { int? | str? }.description(
88
+ "Returns tasks with planned date on exactly this day. Accepts: number (0 = today, 1 = tomorrow), " \
89
+ "'today', 'tomorrow', 'this week', 'next week', or ISO date 'YYYY-MM-DD'"
90
+ )
91
+ optional(:addedWithin).filled(:integer).description(
92
+ "Returns items added (created) within the last N days. Example: 7 = items added in the last week"
93
+ )
94
+ optional(:addedOn).filled(:integer).description(
95
+ "Returns items added (created) on exactly this day. 0 = today, 1 = tomorrow, -1 = yesterday. " \
96
+ "Negative values look backward"
97
+ )
98
+ optional(:isRepeating)
99
+ .filled(:bool)
100
+ .description("Filter by repeating status. true = only repeating tasks, false = only non-repeating tasks")
101
+ optional(:completedWithin).filled(:integer).description(
102
+ "Returns items completed or dropped within the last N days (uses completionDate which OmniFocus " \
103
+ "sets for both). Example: 7 = items completed in the last week. Combine with status: ['Dropped'] " \
104
+ "to find only dropped items. Note: use with includeCompleted: true"
105
+ )
106
+ optional(:completedOn).filled(:integer).description(
107
+ "Returns items completed or dropped on exactly this day (uses completionDate which OmniFocus sets " \
108
+ "for both). 0 = today, -1 = yesterday. Negative values look backward. Combine with status: " \
109
+ "['Dropped'] to find only dropped items. Note: use with includeCompleted: true"
110
+ )
111
+ end.description(
112
+ "Optional filters to narrow results. ALL filters combine with AND logic (must match all). " \
113
+ "Within array filters (tags, status) OR logic applies"
114
+ )
115
+
116
+ optional(:fields).array(:string).description(
117
+ "Specific fields to return (reduces response size). TASK FIELDS: id, name, note, flagged, " \
118
+ "taskStatus, dueDate, deferDate, plannedDate, effectiveDueDate, effectiveDeferDate, " \
119
+ "effectivePlannedDate, completionDate, estimatedMinutes, tagNames, tags, projectName, projectId, " \
120
+ "parentId, childIds, hasChildren, sequential, completedByChildren, inInbox, isRepeating, " \
121
+ "repetitionRule, modificationDate (or modified), creationDate (or added). PROJECT FIELDS: id, " \
122
+ "name, status, note, folderName, folderID, sequential, dueDate, deferDate, effectiveDueDate, " \
123
+ "effectiveDeferDate, completedByChildren, containsSingletonActions, taskCount, tasks, " \
124
+ "modificationDate, creationDate. FOLDER FIELDS: id, name, path, parentFolderID, status, " \
125
+ "projectCount, projects, subfolders. NOTE: Date fields use 'added' and 'modified' in OmniFocus API"
126
+ )
127
+ optional(:limit)
128
+ .filled(:integer)
129
+ .description("Maximum number of items to return. Useful for large result sets. Default: no limit")
130
+ optional(:sortBy).filled(:string).description(
131
+ "Field to sort by. OPTIONS: name (alphabetical), dueDate (earliest first, null last), " \
132
+ "deferDate (earliest first, null last), modificationDate (most recent first), creationDate " \
133
+ "(oldest first), estimatedMinutes (shortest first), taskStatus (groups by status)"
134
+ )
135
+ optional(:sortOrder).filled(included_in?: %w[asc desc]).description(
136
+ "Sort order. 'asc' = ascending (A-Z, old-new, small-large), 'desc' = descending (Z-A, new-old, " \
137
+ "large-small). Default: 'asc'"
138
+ )
139
+ optional(:includeCompleted).filled(:bool).description(
140
+ "Include completed and dropped items. Default: false (active items only)"
141
+ )
142
+ optional(:format).filled(included_in?: %w[text json]).description(
143
+ "Output format. 'text' returns the default human-readable report; 'json' returns structured JSON."
144
+ )
145
+ optional(:summary)
146
+ .filled(:bool)
147
+ .description("Return only count of matches, not full details. Efficient for statistics. Default: false")
148
+ end
149
+ # rubocop:enable Metrics/BlockLength
150
+
151
+ extend OperationFactory
152
+
153
+ default_operation_factory { Operations::QueryOmnifocus.method(:call) }
154
+
155
+ def call(**args)
156
+ McpEnvelope.safely("executing query") do
157
+ params = resolve_date_filters(Params::QueryOmnifocusParams.from_mcp(args))
158
+
159
+ operation.call(params).fold(
160
+ on_ok: lambda { |match|
161
+ McpEnvelope::ToolReply.success(Presenters::QueryReply.format(args:, params:, match:))
162
+ },
163
+ on_error: ->(err) { McpEnvelope::ToolReply.failure(Presenters::QueryReply.failure(err)) }
164
+ )
165
+ end
166
+ end
167
+
168
+ private
169
+
170
+ # Resolve named/ISO date filters before querying. Expects a
171
+ # {Params::QueryOmnifocusParams} whose +filters+ hash is already
172
+ # snake_case.
173
+ def resolve_date_filters(params)
174
+ return params unless params.filters
175
+
176
+ f = params.filters.dup
177
+ DATE_FILTER_FIELDS.each do |field|
178
+ next if f[field].nil?
179
+
180
+ f[field] = Utils::DateFilter.to_days(Utils::DateFilter.parse(f[field]))
181
+ end
182
+ Params::QueryOmnifocusParams.new(**params.to_h, filters: f)
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end