things-mcp 0.1.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.
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "database"
5
+ require_relative "url_scheme"
6
+ require_relative "formatters"
7
+
8
+ module ThingsMcp
9
+ # Tool call handlers for MCP server
10
+ #
11
+ # This module contains handlers for all MCP tool calls, routing requests to appropriate database or URL scheme
12
+ # operations and formatting responses for the MCP client.
13
+ module Handlers
14
+ extend self
15
+
16
+ def handle_tool_call(name, arguments)
17
+ case name
18
+ # Basic operations
19
+ when "get-todos"
20
+ handle_get_todos(arguments)
21
+ when "get-projects"
22
+ handle_get_projects(arguments)
23
+ when "get-areas"
24
+ handle_get_areas(arguments)
25
+
26
+ # List views
27
+ when "get-inbox"
28
+ handle_list_view(:inbox)
29
+ when "get-today"
30
+ handle_list_view(:today)
31
+ when "get-upcoming"
32
+ handle_list_view(:upcoming)
33
+ when "get-anytime"
34
+ handle_list_view(:anytime)
35
+ when "get-someday"
36
+ handle_list_view(:someday)
37
+ when "get-logbook"
38
+ handle_get_logbook(arguments)
39
+ when "get-trash"
40
+ handle_list_view(:trash)
41
+
42
+ # Tag operations
43
+ when "get-tags"
44
+ handle_get_tags(arguments)
45
+ when "get-tagged-items"
46
+ handle_get_tagged_items(arguments)
47
+
48
+ # Search operations
49
+ when "search-todos"
50
+ handle_search_todos(arguments)
51
+ when "search-advanced"
52
+ handle_search_advanced(arguments)
53
+
54
+ # Recent items
55
+ when "get-recent"
56
+ handle_get_recent(arguments)
57
+
58
+ # URL scheme operations
59
+ when "add-todo"
60
+ handle_add_todo(arguments)
61
+ when "add-project"
62
+ handle_add_project(arguments)
63
+ when "update-todo"
64
+ handle_update_todo(arguments)
65
+ when "update-project"
66
+ handle_update_project(arguments)
67
+ when "search-items"
68
+ handle_search_items(arguments)
69
+ when "show-item"
70
+ handle_show_item(arguments)
71
+
72
+ else
73
+ raise "Unknown tool: #{name}"
74
+ end
75
+ end
76
+
77
+ private
78
+
79
+ def handle_get_todos(args)
80
+ todos = Database.get_todos(
81
+ project_uuid: args["project_uuid"],
82
+ include_items: args.fetch("include_items", true),
83
+ )
84
+
85
+ return "No todos found" if todos.empty?
86
+
87
+ todos.map { |todo| Formatters.format_todo(todo) }.join("\n\n")
88
+ end
89
+
90
+ def handle_get_projects(args)
91
+ projects = Database.get_projects(
92
+ include_items: args.fetch("include_items", false),
93
+ )
94
+
95
+ return "No projects found" if projects.empty?
96
+
97
+ projects.map { |project| Formatters.format_project(project) }.join("\n\n")
98
+ end
99
+
100
+ def handle_get_areas(args)
101
+ areas = Database.get_areas(
102
+ include_items: args.fetch("include_items", false),
103
+ )
104
+
105
+ return "No areas found" if areas.empty?
106
+
107
+ areas.map { |area| Formatters.format_area(area) }.join("\n\n")
108
+ end
109
+
110
+ def handle_list_view(list_name)
111
+ todos = case list_name
112
+ when :inbox then Database.get_inbox
113
+ when :today then Database.get_today
114
+ when :upcoming then Database.get_upcoming
115
+ when :anytime then Database.get_anytime
116
+ when :someday then Database.get_someday
117
+ when :trash then Database.get_trash
118
+ end
119
+
120
+ return "No todos in #{list_name.to_s.capitalize}" if todos.empty?
121
+
122
+ header = "# #{list_name.to_s.capitalize}\n\n"
123
+ header + todos.map { |todo| Formatters.format_todo(todo) }.join("\n\n")
124
+ end
125
+
126
+ def handle_get_logbook(args)
127
+ todos = Database.get_logbook(
128
+ period: args.fetch("period", "7d"),
129
+ limit: args.fetch("limit", 50),
130
+ )
131
+
132
+ return "No completed todos found" if todos.empty?
133
+
134
+ "# Logbook\n\n" + todos.map { |todo| Formatters.format_todo(todo) }.join("\n\n")
135
+ end
136
+
137
+ def handle_get_tags(args)
138
+ tags = Database.get_tags(
139
+ include_items: args.fetch("include_items", false),
140
+ )
141
+
142
+ return "No tags found" if tags.empty?
143
+
144
+ tags.map { |tag| Formatters.format_tag(tag) }.join("\n")
145
+ end
146
+
147
+ def handle_get_tagged_items(args)
148
+ tag = args.fetch("tag")
149
+ items = Database.get_tagged_items(tag)
150
+
151
+ return "No items found with tag '#{tag}'" if items.empty?
152
+
153
+ "# Items tagged with '#{tag}'\n\n" +
154
+ items.map { |item| Formatters.format_todo(item) }.join("\n\n")
155
+ end
156
+
157
+ def handle_search_todos(args)
158
+ query = args.fetch("query")
159
+ todos = Database.search_todos(query)
160
+
161
+ return "No todos found matching '#{query}'" if todos.empty?
162
+
163
+ "# Search results for '#{query}'\n\n" +
164
+ todos.map { |todo| Formatters.format_todo(todo) }.join("\n\n")
165
+ end
166
+
167
+ def handle_search_advanced(args)
168
+ todos = Database.search_advanced(args)
169
+
170
+ return "No todos found matching criteria" if todos.empty?
171
+
172
+ "# Advanced search results\n\n" +
173
+ todos.map { |todo| Formatters.format_todo(todo) }.join("\n\n")
174
+ end
175
+
176
+ def handle_get_recent(args)
177
+ period = args.fetch("period")
178
+ todos = Database.get_recent(period)
179
+
180
+ return "No recent items found" if todos.empty?
181
+
182
+ "# Recent items (last #{period})\n\n" +
183
+ todos.map { |todo| Formatters.format_todo(todo) }.join("\n\n")
184
+ end
185
+
186
+ # URL scheme handlers
187
+ def handle_add_todo(args)
188
+ result = UrlScheme.add_todo(args)
189
+
190
+ if result[:success]
191
+ "✅ Todo created: #{args["title"]}"
192
+ else
193
+ "❌ Failed to create todo: #{result[:error]}"
194
+ end
195
+ end
196
+
197
+ def handle_add_project(args)
198
+ result = UrlScheme.add_project(args)
199
+
200
+ if result[:success]
201
+ "✅ Project created: #{args["title"]}"
202
+ else
203
+ "❌ Failed to create project: #{result[:error]}"
204
+ end
205
+ end
206
+
207
+ def handle_update_todo(args)
208
+ unless ENV["THINGS_AUTH_TOKEN"]
209
+ return "❌ Update operations require authentication. Please set THINGS_AUTH_TOKEN environment variable. " +
210
+ "See README for setup instructions."
211
+ end
212
+
213
+ result = UrlScheme.update_todo(args)
214
+
215
+ if result[:success]
216
+ "✅ Todo updated"
217
+ else
218
+ "❌ Failed to update todo: #{result[:error]}"
219
+ end
220
+ end
221
+
222
+ def handle_update_project(args)
223
+ unless ENV["THINGS_AUTH_TOKEN"]
224
+ return "❌ Update operations require authentication. Please set THINGS_AUTH_TOKEN environment variable. " +
225
+ "See README for setup instructions."
226
+ end
227
+
228
+ result = UrlScheme.update_project(args)
229
+
230
+ if result[:success]
231
+ "✅ Project updated"
232
+ else
233
+ "❌ Failed to update project: #{result[:error]}"
234
+ end
235
+ end
236
+
237
+ def handle_search_items(args)
238
+ result = UrlScheme.search_items(args["query"])
239
+
240
+ if result[:success]
241
+ "✅ Opened search in Things for: #{args["query"]}"
242
+ else
243
+ "❌ Failed to open search: #{result[:error]}"
244
+ end
245
+ end
246
+
247
+ def handle_show_item(args)
248
+ result = UrlScheme.show_item(args)
249
+
250
+ if result[:success]
251
+ "✅ Opened item in Things"
252
+ else
253
+ "❌ Failed to show item: #{result[:error]}"
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
4
+ require "mcp/transports/stdio"
5
+ require "logger"
6
+ require_relative "tools"
7
+ require_relative "handlers"
8
+ require_relative "database"
9
+ require_relative "url_scheme"
10
+
11
+ module ThingsMcp
12
+ # MCP server implementation for Things 3 integration
13
+ #
14
+ # This class creates and runs an MCP server that provides tools for interacting with Things 3. It sets up tool
15
+ # definitions, handles tool calls, and manages the stdio transport.
16
+ class Server
17
+ def initialize
18
+ # Check Ruby version first
19
+ check_ruby_version
20
+
21
+ @logger = Logger.new($stderr)
22
+ @logger.level = Logger::INFO
23
+
24
+ # Create all tool classes
25
+ create_tool_classes
26
+
27
+ @server = MCP::Server.new(
28
+ name: "things",
29
+ version: "0.1.0",
30
+ tools: @tool_classes
31
+ )
32
+ rescue => e
33
+ $stderr.puts "ERROR during initialization: #{e.class}: #{e.message}"
34
+ $stderr.puts "Backtrace:"
35
+ e.backtrace.each { |line| $stderr.puts " #{line}" }
36
+ $stderr.flush
37
+ raise
38
+ end
39
+
40
+ def run
41
+ @logger.info("Starting Things MCP server...")
42
+
43
+ # Check if Things app is available
44
+ unless ThingsMcp::Database.things_app_available?
45
+ @logger.warn("Things app is not running. Will attempt to launch when needed.")
46
+ end
47
+
48
+ # Run the server using stdio
49
+ transport = MCP::Transports::StdioTransport.new(@server)
50
+ transport.open
51
+ rescue => e
52
+ # Output to stderr so Claude can see the error
53
+ $stderr.puts "ERROR: #{e.class}: #{e.message}"
54
+ $stderr.puts "Backtrace:"
55
+ e.backtrace.each { |line| $stderr.puts " #{line}" }
56
+ $stderr.flush
57
+ raise
58
+ end
59
+
60
+ private
61
+
62
+ def check_ruby_version
63
+ required_version = Gem::Version.new("3.2.0")
64
+ current_version = Gem::Version.new(RUBY_VERSION)
65
+
66
+ return if current_version >= required_version
67
+
68
+ $stderr.puts "❌ Ruby #{RUBY_VERSION} is too old! This server requires Ruby 3.2+"
69
+ $stderr.flush
70
+ exit 1
71
+ end
72
+
73
+ def create_tool_classes
74
+ @tool_classes = []
75
+
76
+ # Create a tool class for each tool definition
77
+ ThingsMcp::Tools.all.each do |tool_def|
78
+ tool_class = create_tool_class(tool_def)
79
+ @tool_classes << tool_class
80
+ end
81
+
82
+ @logger.info("Created #{@tool_classes.size} tool classes")
83
+ end
84
+
85
+ def create_tool_class(tool_def)
86
+ name = tool_def[:name]
87
+
88
+ Class.new(MCP::Tool) do
89
+ tool_name name
90
+ description tool_def[:description]
91
+
92
+ input_schema(
93
+ type: tool_def[:inputSchema][:type],
94
+ properties: tool_def[:inputSchema][:properties],
95
+ required: tool_def[:inputSchema][:required],
96
+ )
97
+
98
+ define_singleton_method(:call) do |server_context:, **arguments|
99
+ # Convert symbol keys to string keys for consistent access
100
+ string_arguments = arguments.transform_keys(&:to_s)
101
+ result = ThingsMcp::Handlers.handle_tool_call(name, string_arguments)
102
+
103
+ MCP::Tool::Response.new([
104
+ {
105
+ type: "text",
106
+ text: result,
107
+ },
108
+ ])
109
+ rescue => e
110
+ MCP::Tool::Response.new([
111
+ {
112
+ type: "text",
113
+ text: "Error: #{e.message}",
114
+ },
115
+ ])
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end