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,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
|