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,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../infrastructure/script_runner"
4
+ require_relative "../../result"
5
+ require_relative "../../utils/blank"
6
+ require_relative "../generators/perspective_view"
7
+ require_relative "../params"
8
+
9
+ module OmnifocusMcp
10
+ module Tools
11
+ module Operations
12
+ class GetPerspectiveView
13
+ DEFAULT_LIMIT = 100
14
+
15
+ class << self
16
+ def call(params = nil, script_runner: Infrastructure::ScriptRunner, **kwargs)
17
+ params = merge_params(params, kwargs)
18
+ new(script_runner:).call(params)
19
+ end
20
+
21
+ def normalize_limit(value) = new.normalize_limit(value)
22
+
23
+ def classify_response(response:, fields:, limit:)
24
+ new.classify_response(response:, fields:, limit:)
25
+ end
26
+
27
+ def shape_items(items:, fields:, limit:)
28
+ new.shape_items(items:, fields:, limit:)
29
+ end
30
+
31
+ def project_fields(items:, fields:)
32
+ new.project_fields(items:, fields:)
33
+ end
34
+
35
+ private
36
+
37
+ def merge_params(params, kwargs)
38
+ return params || {} if kwargs.empty?
39
+
40
+ base = params.respond_to?(:to_h) ? params.to_h : params || {}
41
+ base.merge(kwargs)
42
+ end
43
+ end
44
+
45
+ def initialize(script_runner: Infrastructure::ScriptRunner, generator: Generators::PerspectiveView)
46
+ @script_runner = script_runner
47
+ @generator = generator
48
+ end
49
+
50
+ def call(params)
51
+ Params::McpBoundary.coerce(Params::GetPerspectiveViewParams, params).then do |params|
52
+ perspective_name = params.perspective_name.to_s
53
+ return OmnifocusMcp::Result.error("Perspective name is required") if Utils::Blank.blank?(perspective_name)
54
+
55
+ limit = normalize_limit(params.limit)
56
+ fields = params.fields
57
+ args = generator.args(perspective_name:, limit:)
58
+
59
+ script_runner.execute_omnifocus_script(generator.script_path, args:)
60
+ .and_then { |response| classify_response(response:, fields:, limit:) }
61
+ end
62
+ rescue StandardError => e
63
+ OmnifocusMcp.logger.warn("[get_perspective_view] Error: #{e}")
64
+ OmnifocusMcp::Result.error(e.message || "Unknown error occurred")
65
+ end
66
+
67
+ def normalize_limit(value)
68
+ return DEFAULT_LIMIT if value.nil?
69
+ return value if value.is_a?(Integer) && value.positive?
70
+
71
+ DEFAULT_LIMIT
72
+ end
73
+
74
+ def classify_response(response:, fields:, limit:)
75
+ shape = response.is_a?(Hash) ? response.transform_keys(&:to_sym) : nil
76
+
77
+ case shape
78
+ in { error: String => msg }
79
+ OmnifocusMcp::Result.error(msg)
80
+ in { items: Array => items }
81
+ OmnifocusMcp::Result.ok(shape_items(items:, fields:, limit:))
82
+ in Hash
83
+ OmnifocusMcp::Result.ok([])
84
+ in nil
85
+ OmnifocusMcp::Result.error("Unexpected response from getPerspectiveView.js: #{response.inspect}")
86
+ end
87
+ end
88
+
89
+ def shape_items(items:, fields:, limit:)
90
+ items = project_fields(items:, fields:) if fields && !fields.empty?
91
+ items = items.first(limit) if items.length > limit
92
+ items
93
+ end
94
+
95
+ def project_fields(items:, fields:)
96
+ string_fields = fields.map(&:to_s)
97
+ items.map do |item|
98
+ next item unless item.is_a?(Hash)
99
+
100
+ string_fields.each_with_object({}) do |field, projected|
101
+ projected[field] = item[field] if item.key?(field)
102
+ end
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ attr_reader :script_runner, :generator
109
+ end
110
+ end
111
+ end
112
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../infrastructure/script_runner"
4
+ require_relative "../../result"
5
+ require_relative "../generators/list_perspectives"
6
+ require_relative "../params"
7
+
8
+ module OmnifocusMcp
9
+ module Tools
10
+ module Operations
11
+ class ListPerspectives
12
+ class << self
13
+ def call(params = nil, script_runner: Infrastructure::ScriptRunner, **kwargs)
14
+ merge_params(params, kwargs).then { |params| new(script_runner:).call(params) }
15
+ end
16
+
17
+ def classify_response(response:, include_built_in:, include_custom:)
18
+ new.classify_response(response:, include_built_in:, include_custom:)
19
+ end
20
+
21
+ def filter_perspectives(perspectives:, include_built_in:, include_custom:)
22
+ new.filter_perspectives(perspectives:, include_built_in:, include_custom:)
23
+ end
24
+
25
+ private
26
+
27
+ def merge_params(params, kwargs)
28
+ return params || {} if kwargs.empty?
29
+
30
+ base = params.respond_to?(:to_h) ? params.to_h : params || {}
31
+ base.merge(kwargs)
32
+ end
33
+ end
34
+
35
+ def initialize(script_runner: Infrastructure::ScriptRunner, generator: Generators::ListPerspectives)
36
+ @script_runner = script_runner
37
+ @generator = generator
38
+ end
39
+
40
+ def call(params = {})
41
+ params = Params::McpBoundary.coerce(Params::ListPerspectivesParams, params)
42
+
43
+ script_runner.execute_omnifocus_script(generator.script_path)
44
+ .and_then do |response|
45
+ classify_response(
46
+ response:,
47
+ include_built_in: params.include_built_in,
48
+ include_custom: params.include_custom
49
+ )
50
+ end
51
+ rescue StandardError => e
52
+ OmnifocusMcp.logger.warn("[list_perspectives] Error: #{e}")
53
+ OmnifocusMcp::Result.error(e.message || "Unknown error occurred")
54
+ end
55
+
56
+ def classify_response(response:, include_built_in:, include_custom:)
57
+ shape = response.is_a?(Hash) ? response.transform_keys(&:to_sym) : nil
58
+
59
+ case shape
60
+ in { error: String => msg }
61
+ OmnifocusMcp::Result.error(msg)
62
+ in { perspectives: Array => perspectives }
63
+ OmnifocusMcp::Result.ok(
64
+ filter_perspectives(perspectives:, include_built_in:, include_custom:)
65
+ )
66
+ in Hash
67
+ OmnifocusMcp::Result.ok([])
68
+ in nil
69
+ OmnifocusMcp::Result.error("Unexpected response from listPerspectives.js: #{response.inspect}")
70
+ end
71
+ end
72
+
73
+ def filter_perspectives(perspectives:, include_built_in:, include_custom:)
74
+ perspectives = perspectives.reject { |perspective| perspective["type"] == "builtin" } unless include_built_in
75
+ perspectives = perspectives.reject { |perspective| perspective["type"] == "custom" } unless include_custom
76
+ perspectives
77
+ end
78
+
79
+ private
80
+
81
+ attr_reader :script_runner, :generator
82
+ end
83
+ end
84
+ end
85
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../infrastructure/script_runner"
4
+ require_relative "../../result"
5
+ require_relative "../generators/list_tags"
6
+ require_relative "../params"
7
+
8
+ module OmnifocusMcp
9
+ module Tools
10
+ module Operations
11
+ class ListTags
12
+ class << self
13
+ def call(params = nil, script_runner: Infrastructure::ScriptRunner, **kwargs)
14
+ params = merge_params(params, kwargs)
15
+ new(script_runner:).call(params)
16
+ end
17
+
18
+ def classify_response(response:, include_dropped:)
19
+ new.classify_response(response:, include_dropped:)
20
+ end
21
+
22
+ def filter_tags(tags:, include_dropped:)
23
+ new.filter_tags(tags:, include_dropped:)
24
+ end
25
+
26
+ private
27
+
28
+ def merge_params(params, kwargs)
29
+ return params || {} if kwargs.empty?
30
+
31
+ base = params.respond_to?(:to_h) ? params.to_h : params || {}
32
+ base.merge(kwargs)
33
+ end
34
+ end
35
+
36
+ def initialize(script_runner: Infrastructure::ScriptRunner, generator: Generators::ListTags)
37
+ @script_runner = script_runner
38
+ @generator = generator
39
+ end
40
+
41
+ def call(params = {})
42
+ params = Params::McpBoundary.coerce(Params::ListTagsParams, params)
43
+
44
+ script_runner.execute_omnifocus_script(generator.script_path)
45
+ .and_then do |response|
46
+ classify_response(response:, include_dropped: params.include_dropped)
47
+ end
48
+ rescue StandardError => e
49
+ OmnifocusMcp.logger.warn("[list_tags] Error: #{e}")
50
+ OmnifocusMcp::Result.error(e.message || "Unknown error occurred")
51
+ end
52
+
53
+ def classify_response(response:, include_dropped:)
54
+ shape = response.is_a?(Hash) ? response.transform_keys(&:to_sym) : nil
55
+
56
+ case shape
57
+ in { error: String => msg }
58
+ OmnifocusMcp::Result.error(msg)
59
+ in { tags: Array => tags }
60
+ OmnifocusMcp::Result.ok(filter_tags(tags:, include_dropped:))
61
+ in Hash
62
+ OmnifocusMcp::Result.ok([])
63
+ in nil
64
+ OmnifocusMcp::Result.error("Unexpected response from listTags.js: #{response.inspect}")
65
+ end
66
+ end
67
+
68
+ def filter_tags(tags:, include_dropped:)
69
+ return tags if include_dropped
70
+
71
+ tags.select { |tag| tag["active"] }
72
+ end
73
+
74
+ private
75
+
76
+ attr_reader :script_runner, :generator
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../infrastructure/script_runner"
4
+ require_relative "../../result"
5
+ require_relative "../generators/query_omnifocus"
6
+ require_relative "../params"
7
+
8
+ module OmnifocusMcp
9
+ module Tools
10
+ module Operations
11
+ class QueryOmnifocus
12
+ Match = Data.define(:items, :count)
13
+
14
+ class << self
15
+ def call(params = nil, script_runner: Infrastructure::ScriptRunner, **kwargs)
16
+ merge_params(params, kwargs).then { new(script_runner:).call(it) }
17
+ end
18
+
19
+ def classify_response(...) = new.classify_response(...)
20
+ def build_match(...) = new.build_match(...)
21
+ def generate_query_script(...) = Generators::QueryOmnifocus.generate_query_script(...)
22
+
23
+ private
24
+
25
+ def merge_params(params, kwargs)
26
+ return params || {} if kwargs.empty?
27
+
28
+ base = params.respond_to?(:to_h) ? params.to_h : params || {}
29
+ base.merge(kwargs)
30
+ end
31
+ end
32
+
33
+ def initialize(script_runner: Infrastructure::ScriptRunner, generator: Generators::QueryOmnifocus)
34
+ @script_runner = script_runner
35
+ @generator = generator
36
+ end
37
+
38
+ def call(params)
39
+ params = Params::McpBoundary.coerce(Params::QueryOmnifocusParams, params)
40
+ generator.generate_query_script(params).then do |script|
41
+ script_runner.execute_omnifocus_source(script)
42
+ .and_then { |response| classify_response(response:, summary: params.summary == true) }
43
+ end
44
+ rescue StandardError => e
45
+ OmnifocusMcp.logger.warn("[query_omnifocus] Error: #{e}")
46
+ OmnifocusMcp::Result.error(e.message || "Unknown error occurred")
47
+ end
48
+
49
+ def classify_response(response:, summary:)
50
+ shape = response.is_a?(Hash) ? response.transform_keys(&:to_sym) : nil
51
+
52
+ case shape
53
+ in { error: String => msg }
54
+ OmnifocusMcp::Result.error(msg)
55
+ in { items: Array => items, count: Integer => count }
56
+ OmnifocusMcp::Result.ok(build_match(items:, count:, summary: summary))
57
+ in { count: Integer => count }
58
+ OmnifocusMcp::Result.ok(Match.new(items: nil, count: count))
59
+ in Hash
60
+ OmnifocusMcp::Result.ok(Match.new(items: nil, count: nil))
61
+ in nil
62
+ OmnifocusMcp::Result.error("Unexpected response from queryOmnifocus: #{response.inspect}")
63
+ end
64
+ end
65
+
66
+ def build_match(items:, count:, summary:) = Match.new(items: summary ? nil : items, count: count)
67
+
68
+ private
69
+
70
+ attr_reader :script_runner, :generator
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../infrastructure/script_runner"
4
+ require_relative "../../result"
5
+ require_relative "../generators/query_omnifocus_debug"
6
+
7
+ module OmnifocusMcp
8
+ module Tools
9
+ module Operations
10
+ class QueryOmnifocusDebug
11
+ ENTITIES = %w[task project folder].freeze
12
+ private_constant :ENTITIES
13
+
14
+ class << self
15
+ def call(entity, script_runner: Infrastructure::ScriptRunner)
16
+ new(script_runner:).call(entity)
17
+ end
18
+
19
+ def generate_debug_script(...) = Generators::QueryOmnifocusDebug.generate_debug_script(...)
20
+ end
21
+
22
+ def initialize(script_runner: Infrastructure::ScriptRunner, generator: Generators::QueryOmnifocusDebug)
23
+ @script_runner = script_runner
24
+ @generator = generator
25
+ end
26
+
27
+ def call(entity)
28
+ normalized = entity.to_s
29
+ return OmnifocusMcp::Result.error(unknown_entity_message(normalized)) unless ENTITIES.include?(normalized)
30
+
31
+ generator.generate_debug_script(normalized).then do |script|
32
+ script_runner.execute_omnifocus_source(script)
33
+ .and_then { |response| classify_response(response) }
34
+ end
35
+ rescue StandardError => e
36
+ OmnifocusMcp.logger.warn("[query_omnifocus_debug] Error: #{e}")
37
+ OmnifocusMcp::Result.error(e.message || "Unknown error in query_omnifocus_debug")
38
+ end
39
+
40
+ private
41
+
42
+ attr_reader :script_runner, :generator
43
+
44
+ def classify_response(response)
45
+ shape = response.is_a?(Hash) ? response.transform_keys(&:to_sym) : nil
46
+
47
+ case shape
48
+ in { error: String => msg }
49
+ OmnifocusMcp::Result.error(msg)
50
+ in Hash
51
+ OmnifocusMcp::Result.ok(response)
52
+ in nil
53
+ OmnifocusMcp::Result.error("Unexpected response from query_omnifocus_debug: #{response.inspect}")
54
+ end
55
+ end
56
+
57
+ def unknown_entity_message(entity)
58
+ "Unknown entity: #{entity.inspect}. Must be one of #{ENTITIES.join(", ")}"
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../../infrastructure/script_runner"
4
+ require_relative "../../parsers/apple_script_envelope"
5
+ require_relative "../../result"
6
+ require_relative "../generators/remove_item"
7
+ require_relative "../params"
8
+
9
+ module OmnifocusMcp
10
+ module Tools
11
+ module Operations
12
+ class RemoveItem
13
+ Removed = Data.define(:id, :name)
14
+
15
+ class << self
16
+ def call(params = nil, script_runner: Infrastructure::ScriptRunner, **kwargs)
17
+ merge_params(params, kwargs).then { new(script_runner:).call(it) }
18
+ end
19
+
20
+ def generate_apple_script(params = nil, **)
21
+ Generators::RemoveItem.generate_apple_script(params, **)
22
+ end
23
+
24
+ private
25
+
26
+ def merge_params(params, kwargs)
27
+ return params || {} if kwargs.empty?
28
+
29
+ base = params.respond_to?(:to_h) ? params.to_h : params || {}
30
+ base.merge(kwargs)
31
+ end
32
+ end
33
+
34
+ def initialize(script_runner: Infrastructure::ScriptRunner, generator: Generators::RemoveItem)
35
+ @script_runner = script_runner
36
+ @generator = generator
37
+ end
38
+
39
+ def call(params)
40
+ params = Params::McpBoundary.coerce(Params::RemoveItemParams, params)
41
+ generator.generate_apple_script(params).then { |script| run_script(script) }
42
+ rescue StandardError => e
43
+ OmnifocusMcp.logger.warn("[remove_item] Error: #{e}")
44
+ OmnifocusMcp::Result.error(e.message || "Unknown error in remove_item")
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :script_runner, :generator
50
+
51
+ def run_script(script)
52
+ stdout, stderr, status = script_runner.execute_applescript(script)
53
+
54
+ OmnifocusMcp.logger.warn("[remove_item] AppleScript stderr: #{stderr}") if stderr && !stderr.empty?
55
+ return OmnifocusMcp::Result.error(applescript_run_failure(stderr:, status:)) unless status.success?
56
+
57
+ parse_result(stdout)
58
+ end
59
+
60
+ def parse_result(stdout)
61
+ Parsers::AppleScriptEnvelope.parse(stdout:, default_error: "Unknown error in remove_item") do |hash|
62
+ OmnifocusMcp::Result.ok(Removed.new(id: hash["id"], name: hash["name"]))
63
+ end
64
+ end
65
+
66
+ def applescript_run_failure(stderr:, status:)
67
+ exit_code = status.respond_to?(:exitstatus) ? status.exitstatus : status
68
+ message = "osascript failed (exit #{exit_code})"
69
+ message += ": #{stderr.strip}" unless stderr.nil? || stderr.empty?
70
+ message
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OmnifocusMcp
4
+ module Tools
5
+ module Operations
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../definitions/key_normalizer"
4
+
5
+ module OmnifocusMcp
6
+ module Tools
7
+ module Params
8
+ # Builds {Data.define} param structs from MCP wire args or snake_case Hashes.
9
+ module McpBoundary
10
+ class << self
11
+ # @param klass [Data] struct class whose +members+ list snake_case fields
12
+ # @param args [Hash] raw MCP arguments (camelCase Symbol keys)
13
+ # @param deep [Boolean] recurse into nested Hashes and Arrays of Hashes
14
+ def build(klass, args, deep: false)
15
+ snake = Definitions::KeyNormalizer.snake_keys(args, deep: deep)
16
+ from_snake_hash(klass, snake)
17
+ end
18
+
19
+ # @param klass [Data]
20
+ # @param hash [Hash] snake_case Symbol keys (e.g. from tests or resources)
21
+ def from_hash(klass, hash)
22
+ from_snake_hash(klass, hash || {})
23
+ end
24
+
25
+ def from_snake_hash(klass, snake)
26
+ klass.new(**klass.members.to_h { |member| [member, snake[member]] })
27
+ end
28
+
29
+ # Accept a param struct or a snake_case Hash (e.g. specs, script helpers).
30
+ def coerce(klass, value)
31
+ case value
32
+ when klass then value
33
+ when Hash then klass.from_hash(value)
34
+ else raise ArgumentError, "expected #{klass.name}, got #{value.class}"
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "params/mcp_boundary"
4
+
5
+ module OmnifocusMcp
6
+ module Tools
7
+ # Typed input objects for tool primitives. MCP tools build these via
8
+ # {.from_mcp}; other callers (resources, integration specs) use {.from_hash}.
9
+ module Params
10
+ AddTaskParams = Data.define(
11
+ :name, :note, :due_date, :defer_date, :planned_date,
12
+ :flagged, :estimated_minutes, :tags, :project_name,
13
+ :parent_task_id, :parent_task_name, :hierarchy_level
14
+ ) do
15
+ def self.from_mcp(args) = McpBoundary.build(self, args)
16
+ def self.from_hash(hash) = McpBoundary.from_hash(self, hash)
17
+ end
18
+
19
+ AddProjectParams = Data.define(
20
+ :name, :note, :due_date, :defer_date, :flagged,
21
+ :estimated_minutes, :tags, :folder_name, :sequential
22
+ ) do
23
+ def self.from_mcp(args) = McpBoundary.build(self, args)
24
+ def self.from_hash(hash) = McpBoundary.from_hash(self, hash)
25
+ end
26
+
27
+ EditItemParams = Data.define(
28
+ :id, :name, :item_type, :new_name, :new_note,
29
+ :new_due_date, :new_defer_date, :new_planned_date, :new_flagged,
30
+ :new_estimated_minutes, :new_status, :add_tags, :remove_tags,
31
+ :replace_tags, :new_project_name, :new_sequential, :new_folder_name,
32
+ :new_project_status
33
+ ) do
34
+ def self.from_mcp(args) = McpBoundary.build(self, args)
35
+ def self.from_hash(hash) = McpBoundary.from_hash(self, hash)
36
+ end
37
+
38
+ RemoveItemParams = Data.define(:id, :name, :item_type) do
39
+ def self.from_mcp(args) = McpBoundary.build(self, args)
40
+ def self.from_hash(hash) = McpBoundary.from_hash(self, hash)
41
+ end
42
+
43
+ QueryOmnifocusParams = Data.define(
44
+ :entity, :filters, :fields, :limit, :sort_by, :sort_order,
45
+ :include_completed, :format, :summary
46
+ ) do
47
+ def self.from_mcp(args) = McpBoundary.build(self, args, deep: true)
48
+ def self.from_hash(hash) = McpBoundary.from_hash(self, hash)
49
+ end
50
+
51
+ BatchAddItemParams = Data.define(
52
+ :type, :name, :note, :due_date, :defer_date, :planned_date,
53
+ :flagged, :estimated_minutes, :tags, :project_name, :parent_task_id,
54
+ :parent_task_name, :temp_id, :parent_temp_id, :hierarchy_level,
55
+ :folder_name, :sequential
56
+ ) do
57
+ def self.from_mcp(args) = McpBoundary.build(self, args)
58
+ def self.from_hash(hash) = McpBoundary.from_hash(self, hash)
59
+ end
60
+
61
+ BatchRemoveItemParams = Data.define(:id, :name, :item_type) do
62
+ def self.from_mcp(args) = McpBoundary.build(self, args)
63
+ def self.from_hash(hash) = McpBoundary.from_hash(self, hash)
64
+ end
65
+
66
+ ListPerspectivesParams = Data.define(:include_built_in, :include_custom) do
67
+ def self.from_mcp(args)
68
+ new(
69
+ include_built_in: args.fetch(:includeBuiltIn, true),
70
+ include_custom: args.fetch(:includeCustom, true)
71
+ )
72
+ end
73
+
74
+ def self.from_hash(hash)
75
+ new(
76
+ include_built_in: hash.fetch(:include_built_in, true),
77
+ include_custom: hash.fetch(:include_custom, true)
78
+ )
79
+ end
80
+ end
81
+
82
+ ListTagsParams = Data.define(:include_dropped) do
83
+ def self.from_mcp(args) = new(include_dropped: args[:includeDropped] || false)
84
+ def self.from_hash(hash) = new(include_dropped: hash.fetch(:include_dropped, false))
85
+ end
86
+
87
+ GetPerspectiveViewParams = Data.define(:perspective_name, :limit, :fields) do
88
+ def self.from_mcp(args)
89
+ new(
90
+ perspective_name: args[:perspectiveName],
91
+ limit: args[:limit],
92
+ fields: args[:fields]
93
+ )
94
+ end
95
+
96
+ def self.from_hash(hash)
97
+ new(
98
+ perspective_name: hash[:perspective_name],
99
+ limit: hash[:limit],
100
+ fields: hash[:fields]
101
+ )
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end