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,500 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "sqlite3"
4
+ require "pathname"
5
+ require "date"
6
+ require "uri"
7
+
8
+ module ThingsMcp
9
+ # Database access layer for Things 3 SQLite database
10
+ #
11
+ # This class provides read-only access to the Things 3 database, enabling retrieval of todos, projects, areas, and
12
+ # tags. It handles dynamic database path resolution and provides formatted data structures.
13
+ class Database
14
+
15
+ class << self
16
+ def find_database_path
17
+ group_containers_dir = "#{Dir.home}/Library/Group Containers"
18
+
19
+ # Find Things-specific directories to avoid permission issues
20
+ things_dirs = Dir.glob("#{group_containers_dir}/*").select do |dir|
21
+ File.basename(dir).include?("culturedcode.ThingsMac")
22
+ end
23
+
24
+ things_dirs.each do |things_dir|
25
+ # Try new format first (Things 3.15.16+)
26
+ new_pattern = "#{things_dir}/ThingsData-*/Things Database.thingsdatabase/main.sqlite"
27
+ matches = Dir.glob(new_pattern)
28
+ return matches.first unless matches.empty?
29
+
30
+ # Fall back to old format
31
+ old_pattern = "#{things_dir}/Things Database.thingsdatabase/main.sqlite"
32
+ return old_pattern if File.exist?(old_pattern)
33
+ end
34
+
35
+ # Check environment variable
36
+ ENV["THINGSDB"] if ENV["THINGSDB"] && File.exist?(ENV["THINGSDB"])
37
+ end
38
+
39
+ def database_path
40
+ @database_path ||= find_database_path
41
+ end
42
+
43
+ def things_app_available?
44
+ system('pgrep -x "Things3" > /dev/null 2>&1')
45
+ end
46
+
47
+ def with_database(&block)
48
+ db_path = database_path
49
+ unless db_path
50
+ $stderr.puts "DEBUG: Database path search failed"
51
+ $stderr.puts "DEBUG: Home directory: #{Dir.home}"
52
+ $stderr.puts "DEBUG: Group Containers exists: #{File.exist?("#{Dir.home}/Library/Group Containers")}"
53
+ if File.exist?("#{Dir.home}/Library/Group Containers")
54
+ containers = Dir.glob("#{Dir.home}/Library/Group Containers/*")
55
+ $stderr.puts "DEBUG: Found containers: #{containers.size}"
56
+ things_containers = containers.select { |c| File.basename(c).include?("culturedcode") }
57
+ $stderr.puts "DEBUG: Things containers: #{things_containers}"
58
+ end
59
+ raise "Things database not found. Please ensure Things 3 is installed and has been launched at least once."
60
+ end
61
+
62
+ db = SQLite3::Database.new(db_path, readonly: true)
63
+ db.results_as_hash = true
64
+ yield db
65
+ ensure
66
+ db&.close
67
+ end
68
+
69
+ # Get all todos
70
+ def get_todos(project_uuid: nil, include_items: true)
71
+ with_database do |db|
72
+ query = build_todo_query(project_uuid: project_uuid)
73
+ results = db.execute(query)
74
+
75
+ todos = results.map { |row| format_todo(row) }
76
+
77
+ if include_items
78
+ todos.each do |todo|
79
+ todo[:checklist_items] = get_checklist_items(db, todo[:uuid])
80
+ end
81
+ end
82
+
83
+ # Always fetch tags for todos
84
+ todos.each do |todo|
85
+ todo[:tags] = get_tags_for_task(db, todo[:uuid])
86
+ end
87
+
88
+ todos
89
+ end
90
+ end
91
+
92
+ # Get todos from specific lists
93
+ def get_inbox
94
+ get_todos_by_start(0)
95
+ end
96
+
97
+ def get_today
98
+ get_todos_by_start(1)
99
+ end
100
+
101
+ def get_upcoming
102
+ with_database do |db|
103
+ query = <<~SQL
104
+ SELECT #{todo_columns}
105
+ FROM TMTask
106
+ WHERE type = 0
107
+ AND status = 0
108
+ AND trashed = 0
109
+ AND start = 2
110
+ AND (startDate IS NOT NULL OR deadline IS NOT NULL)
111
+ ORDER BY COALESCE(startDate, deadline)
112
+ SQL
113
+
114
+ db.execute(query).map { |row| format_todo(row) }
115
+ end
116
+ end
117
+
118
+ def get_anytime
119
+ get_todos_by_start(2).reject { |t| t[:start_date] || t[:deadline] }
120
+ end
121
+
122
+ def get_someday
123
+ get_todos_by_start(3)
124
+ end
125
+
126
+ def get_logbook(period: "7d", limit: 50)
127
+ days = parse_period(period)
128
+ cutoff = Date.today - days
129
+
130
+ with_database do |db|
131
+ query = <<~SQL
132
+ SELECT #{todo_columns}
133
+ FROM TMTask
134
+ WHERE type = 0
135
+ AND status IN (3, 2)
136
+ AND trashed = 0
137
+ AND stopDate >= julianday('#{cutoff}')
138
+ ORDER BY stopDate DESC
139
+ LIMIT #{limit}
140
+ SQL
141
+
142
+ db.execute(query).map { |row| format_todo(row) }
143
+ end
144
+ end
145
+
146
+ def get_trash
147
+ with_database do |db|
148
+ query = <<~SQL
149
+ SELECT #{todo_columns}
150
+ FROM TMTask
151
+ WHERE type = 0
152
+ AND trashed = 1
153
+ ORDER BY userModificationDate DESC
154
+ SQL
155
+
156
+ db.execute(query).map { |row| format_todo(row) }
157
+ end
158
+ end
159
+
160
+ # Get projects
161
+ def get_projects(include_items: false)
162
+ with_database do |db|
163
+ query = <<~SQL
164
+ SELECT uuid, title, notes, status, area, start, startDate, deadline,
165
+ creationDate, userModificationDate
166
+ FROM TMTask
167
+ WHERE type = 1
168
+ AND trashed = 0
169
+ ORDER BY userModificationDate DESC
170
+ SQL
171
+
172
+ projects = db.execute(query).map { |row| format_project(row) }
173
+
174
+ if include_items
175
+ projects.each do |project|
176
+ project[:todos] = get_todos(project_uuid: project[:uuid])
177
+ end
178
+ end
179
+
180
+ projects
181
+ end
182
+ end
183
+
184
+ # Get areas
185
+ def get_areas(include_items: false)
186
+ with_database do |db|
187
+ query = <<~SQL
188
+ SELECT uuid, title, tags
189
+ FROM TMArea
190
+ ORDER BY "index"
191
+ SQL
192
+
193
+ areas = db.execute(query).map { |row| format_area(row) }
194
+
195
+ if include_items
196
+ areas.each do |area|
197
+ # Get projects in this area
198
+ project_query = <<~SQL
199
+ SELECT uuid, title
200
+ FROM TMTask
201
+ WHERE type = 1
202
+ AND trashed = 0
203
+ AND area = '#{area[:uuid]}'
204
+ SQL
205
+
206
+ area[:projects] = db.execute(project_query).map do |proj|
207
+ { uuid: proj["uuid"], title: proj["title"] }
208
+ end
209
+ end
210
+ end
211
+
212
+ areas
213
+ end
214
+ end
215
+
216
+ # Get tags
217
+ def get_tags(include_items: false)
218
+ with_database do |db|
219
+ query = "SELECT uuid, title FROM TMTag ORDER BY title"
220
+
221
+ tags = db.execute(query).map { |row| format_tag(row) }
222
+
223
+ if include_items
224
+ tags.each do |tag|
225
+ tag[:items] = get_tagged_items(tag[:title])
226
+ end
227
+ end
228
+
229
+ tags
230
+ end
231
+ end
232
+
233
+ def get_tagged_items(tag_title)
234
+ with_database do |db|
235
+ # Find tag UUID
236
+ tag_result = db.execute("SELECT uuid FROM TMTag WHERE title = ?", [tag_title]).first
237
+ return [] unless tag_result
238
+
239
+ tag_uuid = tag_result["uuid"]
240
+
241
+ query = <<~SQL
242
+ SELECT #{todo_columns}
243
+ FROM TMTask
244
+ WHERE type = 0
245
+ AND status = 0
246
+ AND trashed = 0
247
+ AND tags LIKE '%#{tag_uuid}%'
248
+ ORDER BY userModificationDate DESC
249
+ SQL
250
+
251
+ db.execute(query).map { |row| format_todo(row) }
252
+ end
253
+ end
254
+
255
+ # Search todos
256
+ def search_todos(query)
257
+ with_database do |db|
258
+ search_query = <<~SQL
259
+ SELECT #{todo_columns}
260
+ FROM TMTask
261
+ WHERE type = 0
262
+ AND trashed = 0
263
+ AND (title LIKE '%#{query}%' OR notes LIKE '%#{query}%')
264
+ ORDER BY userModificationDate DESC
265
+ SQL
266
+
267
+ db.execute(search_query).map { |row| format_todo(row) }
268
+ end
269
+ end
270
+
271
+ def search_advanced(filters = {})
272
+ with_database do |db|
273
+ conditions = ["type = 0", "trashed = 0"]
274
+
275
+ # Status filter
276
+ if filters[:status]
277
+ status_map = { "incomplete" => 0, "completed" => 3, "canceled" => 2 }
278
+ conditions << "status = #{status_map[filters[:status]]}"
279
+ end
280
+
281
+ # Date filters
282
+ if filters[:start_date]
283
+ conditions << "startDate >= julianday('#{filters[:start_date]}')"
284
+ end
285
+
286
+ if filters[:deadline]
287
+ conditions << "deadline <= julianday('#{filters[:deadline]}')"
288
+ end
289
+
290
+ # Tag filter
291
+ if filters[:tag]
292
+ tag_result = db.execute("SELECT uuid FROM TMTag WHERE title = ?", [filters[:tag]]).first
293
+ conditions << "tags LIKE '%#{tag_result["uuid"]}%'" if tag_result
294
+ end
295
+
296
+ # Area filter
297
+ if filters[:area]
298
+ conditions << "area = '#{filters[:area]}'"
299
+ end
300
+
301
+ # Type filter
302
+ if filters[:type]
303
+ type_map = { "to-do" => 0, "project" => 1, "heading" => 2 }
304
+ conditions << "type = #{type_map[filters[:type]]}"
305
+ end
306
+
307
+ query = <<~SQL
308
+ SELECT #{todo_columns}
309
+ FROM TMTask
310
+ WHERE #{conditions.join(" AND ")}
311
+ ORDER BY userModificationDate DESC
312
+ SQL
313
+
314
+ db.execute(query).map { |row| format_todo(row) }
315
+ end
316
+ end
317
+
318
+ def get_recent(period)
319
+ days = parse_period(period)
320
+ cutoff = Date.today - days
321
+
322
+ with_database do |db|
323
+ query = <<~SQL
324
+ SELECT #{todo_columns}
325
+ FROM TMTask
326
+ WHERE type = 0
327
+ AND trashed = 0
328
+ AND creationDate >= julianday('#{cutoff}')
329
+ ORDER BY creationDate DESC
330
+ SQL
331
+
332
+ db.execute(query).map { |row| format_todo(row) }
333
+ end
334
+ end
335
+
336
+ private
337
+
338
+ def todo_columns
339
+ "uuid, title, notes, status, project, area, start, startDate, " \
340
+ "deadline, creationDate, userModificationDate"
341
+ end
342
+
343
+ def build_todo_query(project_uuid: nil)
344
+ base_query = <<~SQL
345
+ SELECT #{todo_columns}
346
+ FROM TMTask
347
+ WHERE type = 0
348
+ AND trashed = 0
349
+ SQL
350
+
351
+ base_query += " AND project = '#{project_uuid}'" if project_uuid
352
+ base_query + ' ORDER BY "index"'
353
+ end
354
+
355
+ def get_todos_by_start(start_value)
356
+ with_database do |db|
357
+ query = <<~SQL
358
+ SELECT #{todo_columns}
359
+ FROM TMTask
360
+ WHERE type = 0
361
+ AND status = 0
362
+ AND trashed = 0
363
+ AND start = #{start_value}
364
+ ORDER BY "index"
365
+ SQL
366
+
367
+ db.execute(query).map { |row| format_todo(row) }
368
+ end
369
+ end
370
+
371
+ def get_checklist_items(db, task_uuid)
372
+ query = <<~SQL
373
+ SELECT uuid, title, status
374
+ FROM TMChecklistItem
375
+ WHERE task = '#{task_uuid}'
376
+ ORDER BY "index"
377
+ SQL
378
+
379
+ db.execute(query).map do |item|
380
+ {
381
+ uuid: item["uuid"],
382
+ title: item["title"],
383
+ completed: item["status"] == 3,
384
+ }
385
+ end
386
+ end
387
+
388
+ def get_tags_for_task(db, task_uuid)
389
+ query = <<~SQL
390
+ SELECT TAG.title
391
+ FROM TMTaskTag AS TASK_TAG
392
+ LEFT OUTER JOIN TMTag TAG ON TAG.uuid = TASK_TAG.tags
393
+ WHERE TASK_TAG.tasks = ?
394
+ ORDER BY TAG."index"
395
+ SQL
396
+
397
+ db.execute(query, [task_uuid]).map { |row| row["title"] }.compact
398
+ end
399
+
400
+ def format_todo(row)
401
+ {
402
+ uuid: row["uuid"],
403
+ title: decode_title(row["title"]),
404
+ notes: decode_title(row["notes"]),
405
+ status: format_status(row["status"]),
406
+ project: row["project"],
407
+ area: row["area"],
408
+ tags: [], # Tags will be populated separately
409
+ when: format_when(row["start"]),
410
+ start_date: julian_to_date(row["startDate"]),
411
+ deadline: julian_to_date(row["deadline"]),
412
+ created: julian_to_date(row["creationDate"]),
413
+ modified: julian_to_date(row["userModificationDate"]),
414
+ }
415
+ end
416
+
417
+ def format_project(row)
418
+ {
419
+ uuid: row["uuid"],
420
+ title: decode_title(row["title"]),
421
+ notes: decode_title(row["notes"]),
422
+ status: format_status(row["status"]),
423
+ area: row["area"],
424
+ tags: [], # Tags will be populated separately for projects too
425
+ when: format_when(row["start"]),
426
+ start_date: julian_to_date(row["startDate"]),
427
+ deadline: julian_to_date(row["deadline"]),
428
+ created: julian_to_date(row["creationDate"]),
429
+ modified: julian_to_date(row["userModificationDate"]),
430
+ }
431
+ end
432
+
433
+ def format_area(row)
434
+ {
435
+ uuid: row["uuid"],
436
+ title: row["title"],
437
+ tags: [], # Areas can have tags but we'll implement that separately if needed
438
+ }
439
+ end
440
+
441
+ def format_tag(row)
442
+ {
443
+ uuid: row["uuid"],
444
+ title: row["title"],
445
+ }
446
+ end
447
+
448
+ def format_status(status)
449
+ case status
450
+ when 0 then "incomplete"
451
+ when 2 then "canceled"
452
+ when 3 then "completed"
453
+ else "unknown"
454
+ end
455
+ end
456
+
457
+ def format_when(start)
458
+ case start
459
+ when 0 then "inbox"
460
+ when 1 then "today"
461
+ when 2 then "anytime"
462
+ when 3 then "someday"
463
+ else "unknown"
464
+ end
465
+ end
466
+
467
+ def julian_to_date(julian_days)
468
+ return unless julian_days
469
+
470
+ # SQLite Julian day starts from noon on November 24, 4714 BC
471
+ Date.jd(julian_days.to_i + 2400001).to_s
472
+ end
473
+
474
+ def parse_period(period)
475
+ match = period.match(/^(\d+)([dwmy])$/)
476
+ raise ArgumentError, "Invalid period format: #{period}" unless match
477
+
478
+ number = match[1].to_i
479
+ unit = match[2]
480
+
481
+ case unit
482
+ when "d" then number
483
+ when "w" then number * 7
484
+ when "m" then number * 30
485
+ when "y" then number * 365
486
+ end
487
+ end
488
+
489
+ def decode_title(title)
490
+ return unless title
491
+
492
+ # Decode URL-encoded titles that may come from URL scheme operations
493
+ URI.decode_www_form_component(title.to_s)
494
+ rescue
495
+ # If decoding fails, return original title
496
+ title
497
+ end
498
+ end
499
+ end
500
+ end
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+
5
+ module ThingsMcp
6
+ # Output formatters for Things 3 data
7
+ #
8
+ # This module provides formatting methods to convert raw database records into human-readable text for MCP responses.
9
+ module Formatters
10
+ extend self
11
+
12
+ def format_todo(todo)
13
+ lines = []
14
+
15
+ # Status indicator
16
+ status_icon = case todo[:status]
17
+ when "completed" then "✅"
18
+ when "canceled" then "❌"
19
+ else "⭕"
20
+ end
21
+
22
+ lines << "#{status_icon} **#{todo[:title]}**"
23
+ lines << " UUID: #{todo[:uuid]}" if todo[:uuid]
24
+
25
+ # Notes
26
+ if todo[:notes] && !todo[:notes].empty?
27
+ lines << " Notes: #{todo[:notes].gsub("\n", " ")}"
28
+ end
29
+
30
+ # When/scheduling
31
+ if todo[:when] && todo[:when] != "unknown"
32
+ lines << " When: #{todo[:when]}"
33
+ end
34
+
35
+ if todo[:start_date]
36
+ lines << " Start: #{todo[:start_date]}"
37
+ end
38
+
39
+ if todo[:deadline]
40
+ lines << " Deadline: #{todo[:deadline]}"
41
+ end
42
+
43
+ # Tags
44
+ if todo[:tags] && !todo[:tags].empty?
45
+ lines << " Tags: #{todo[:tags].join(", ")}"
46
+ end
47
+
48
+ # Checklist items
49
+ if todo[:checklist_items] && !todo[:checklist_items].empty?
50
+ lines << " Checklist:"
51
+ todo[:checklist_items].each do |item|
52
+ check = item[:completed] ? "✓" : "○"
53
+ lines << " #{check} #{item[:title]}"
54
+ end
55
+ end
56
+
57
+ # Metadata
58
+ if todo[:created]
59
+ lines << " Created: #{todo[:created]}"
60
+ end
61
+
62
+ if todo[:modified]
63
+ lines << " Modified: #{todo[:modified]}"
64
+ end
65
+
66
+ lines.join("\n")
67
+ end
68
+
69
+ def format_project(project)
70
+ lines = []
71
+
72
+ # Status indicator
73
+ status_icon = case project[:status]
74
+ when "completed" then "✅"
75
+ when "canceled" then "❌"
76
+ else "📁"
77
+ end
78
+
79
+ lines << "#{status_icon} **#{project[:title]}** (Project)"
80
+ lines << " UUID: #{project[:uuid]}" if project[:uuid]
81
+
82
+ # Notes
83
+ if project[:notes] && !project[:notes].empty?
84
+ lines << " Notes: #{project[:notes].gsub("\n", " ")}"
85
+ end
86
+
87
+ # When/scheduling
88
+ if project[:when] && project[:when] != "unknown"
89
+ lines << " When: #{project[:when]}"
90
+ end
91
+
92
+ if project[:start_date]
93
+ lines << " Start: #{project[:start_date]}"
94
+ end
95
+
96
+ if project[:deadline]
97
+ lines << " Deadline: #{project[:deadline]}"
98
+ end
99
+
100
+ # Tags
101
+ if project[:tags] && !project[:tags].empty?
102
+ lines << " Tags: #{project[:tags].join(", ")}"
103
+ end
104
+
105
+ # Todos within project
106
+ if project[:todos] && !project[:todos].empty?
107
+ lines << " Todos:"
108
+ project[:todos].each do |todo|
109
+ status = todo[:status] == "completed" ? "✓" : "○"
110
+ lines << " #{status} #{todo[:title]}"
111
+ end
112
+ end
113
+
114
+ lines.join("\n")
115
+ end
116
+
117
+ def format_area(area)
118
+ lines = []
119
+
120
+ lines << "🏷️ **#{area[:title]}** (Area)"
121
+ lines << " UUID: #{area[:uuid]}" if area[:uuid]
122
+
123
+ # Tags
124
+ if area[:tags] && !area[:tags].empty?
125
+ lines << " Tags: #{area[:tags].join(", ")}"
126
+ end
127
+
128
+ # Projects within area
129
+ if area[:projects] && !area[:projects].empty?
130
+ lines << " Projects:"
131
+ area[:projects].each do |project|
132
+ lines << " 📁 #{project[:title]}"
133
+ end
134
+ end
135
+
136
+ lines.join("\n")
137
+ end
138
+
139
+ def format_tag(tag)
140
+ lines = []
141
+
142
+ lines << "🏷️ #{tag[:title]}"
143
+
144
+ if tag[:items] && !tag[:items].empty?
145
+ lines << " Items: #{tag[:items].size}"
146
+ end
147
+
148
+ lines.join("\n")
149
+ end
150
+
151
+ end
152
+ end