things-mcp 0.1.1 → 0.1.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e31d578d8f6cb134b0d9b326d9178bc2b65c5eb83ceae17da565f033361877da
4
- data.tar.gz: 251493afb608cc788e59d2fc9ec383fbbfc6abd47ee1b6f252f9cc317450e87f
3
+ metadata.gz: c04d398fe0ae4d889f3b1283f170b7ea16d89d3aadb15c0729f02224051fb40c
4
+ data.tar.gz: 63edc670dfefbf746f0397a3a41e95e81ae977237421057e89564b4b77ce998b
5
5
  SHA512:
6
- metadata.gz: 68faf189bd64f1e6e11eb4e68099abc9b65f64ffbffb0b0057efff92f019de56e169b38f8b5fd32dc40f0735435a62a3a2a6cfe97a1c480da1009c40c151a8b3
7
- data.tar.gz: 25e5e7bb0e01c27265af90f70174da40d320d1bc89193e31f5a0965d9fa970422d2edbc85996579e0e50a196ad6aa1c41fd6f99f7c310637d6a90f253ff83d60
6
+ metadata.gz: 749a56ffef198d8bfabf98212faa51e8d47f8b79280a09899910a49938ea20391ebe08e8a5387ef593ec3095bea9aa7d138ca7294058d3b995ed3f4385fc0ee0
7
+ data.tar.gz: 570aeb516c134b4de385a25b672677976879dcccab000be09ba697568de5ff03864ea328a695ba03212c49b28352f5be1e820402f2ed172483aba476df9a1eca
data/bin/test_connection CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require "bundler/setup"
5
- require_relative "../lib/things_mcp"
4
+ $LOAD_PATH.unshift(File.expand_path("../lib", __dir__))
5
+ require "things_mcp"
6
6
 
7
7
  puts "Testing Things MCP Ruby Server"
8
8
  puts "=" * 40
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require "bundler/setup"
5
- require_relative "../lib/things_mcp/server"
4
+ lib = File.expand_path("../lib", __dir__)
5
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
+ require "things_mcp/server"
6
7
 
7
8
  # Run the MCP server
8
9
  ThingsMcp::Server.new.run
@@ -11,6 +11,10 @@ module ThingsMcp
11
11
  # This class provides read-only access to the Things 3 database, enabling retrieval of todos, projects, areas, and
12
12
  # tags. It handles dynamic database path resolution and provides formatted data structures.
13
13
  class Database
14
+ # Things date encoding constants
15
+ UNITS_PER_DAY = 128.0
16
+ EPOCH = Date.new(-814, 4, 1)
17
+
14
18
  class << self
15
19
  def database_path
16
20
  @database_path ||= find_database_path
@@ -62,7 +66,24 @@ module ThingsMcp
62
66
  end
63
67
 
64
68
  def get_today
65
- get_todos_by_start(1)
69
+ # Get todos scheduled for today or overdue (startDate <= today's value)
70
+ today_value = (Date.today - EPOCH) * UNITS_PER_DAY
71
+
72
+ with_database do |db|
73
+ query = <<~SQL
74
+ SELECT #{todo_columns}
75
+ FROM TMTask
76
+ WHERE type = 0
77
+ AND status = 0
78
+ AND trashed = 0
79
+ AND startDate IS NOT NULL
80
+ AND startDate <= #{today_value.to_i}
81
+ ORDER BY "index"
82
+ SQL
83
+
84
+ results = db.execute(query).map { |row| format_todo(row) }
85
+ add_checklist_items_and_tags(db, results)
86
+ end
66
87
  end
67
88
 
68
89
  def get_upcoming
@@ -78,21 +99,56 @@ module ThingsMcp
78
99
  ORDER BY COALESCE(startDate, deadline)
79
100
  SQL
80
101
 
81
- db.execute(query).map { |row| format_todo(row) }
102
+ results = db.execute(query).map { |row| format_todo(row) }
103
+ add_checklist_items_and_tags(db, results)
82
104
  end
83
105
  end
84
106
 
85
107
  def get_anytime
86
- get_todos_by_start(2).reject { |t| t[:start_date] || t[:deadline] }
108
+ with_database do |db|
109
+ query = <<~SQL
110
+ SELECT #{todo_columns}
111
+ FROM TMTask
112
+ WHERE type = 0
113
+ AND status = 0
114
+ AND trashed = 0
115
+ AND start = 1
116
+ ORDER BY "index"
117
+ SQL
118
+
119
+ results = db.execute(query).map { |row| format_todo(row) }
120
+ add_checklist_items_and_tags(db, results)
121
+ end
87
122
  end
88
123
 
89
124
  def get_someday
90
- get_todos_by_start(3)
125
+ # Get todos in someday: start=2 with no dates
126
+ # FIXME: Recurring items (like "Update mileage log") appear here when they should
127
+ # appear in Today or Scheduled based on their next occurrence. Things likely uses
128
+ # additional metadata to track recurring patterns that we haven't implemented.
129
+ with_database do |db|
130
+ query = <<~SQL
131
+ SELECT #{todo_columns}
132
+ FROM TMTask
133
+ WHERE type = 0
134
+ AND status = 0
135
+ AND trashed = 0
136
+ AND start = 2
137
+ AND startDate IS NULL
138
+ AND deadline IS NULL
139
+ ORDER BY "index"
140
+ SQL
141
+
142
+ results = db.execute(query).map { |row| format_todo(row) }
143
+ add_checklist_items_and_tags(db, results)
144
+ end
91
145
  end
92
146
 
93
147
  def get_logbook(period: "7d", limit: 50)
94
148
  days = parse_period(period)
95
149
  cutoff = Date.today - days
150
+ # stopDate is a REAL column with Unix timestamp
151
+ cutoff_timestamp = cutoff.to_time.to_i
96
152
 
97
153
  with_database do |db|
98
154
  query = <<~SQL
@@ -101,7 +157,7 @@ module ThingsMcp
101
157
  WHERE type = 0
102
158
  AND status IN (3, 2)
103
159
  AND trashed = 0
104
- AND stopDate >= julianday('#{cutoff}')
160
+ AND stopDate >= #{cutoff_timestamp}
105
161
  ORDER BY stopDate DESC
106
162
  LIMIT #{limit}
107
163
  SQL
@@ -247,11 +303,17 @@ module ThingsMcp
247
303
 
248
304
  # Date filters
249
305
  if filters[:start_date]
250
- conditions << "startDate >= julianday('#{filters[:start_date]}')"
306
+ # startDate is INTEGER column with NSDate seconds
307
+ nsdate_epoch = Time.new(2001, 1, 1, 0, 0, 0, "+00:00")
308
+ start_timestamp = (Date.parse(filters[:start_date]).to_time - nsdate_epoch).to_i
309
+ conditions << "startDate >= #{start_timestamp}"
251
310
  end
252
311
 
253
312
  if filters[:deadline]
254
- conditions << "deadline <= julianday('#{filters[:deadline]}')"
313
+ # deadline is INTEGER column with NSDate seconds
314
+ nsdate_epoch = Time.new(2001, 1, 1, 0, 0, 0, "+00:00")
315
+ deadline_timestamp = (Date.parse(filters[:deadline]).to_time - nsdate_epoch).to_i
316
+ conditions << "deadline <= #{deadline_timestamp}"
255
317
  end
256
318
 
257
319
  # Tag filter
@@ -285,6 +347,8 @@ module ThingsMcp
285
347
  def get_recent(period)
286
348
  days = parse_period(period)
287
349
  cutoff = Date.today - days
350
+ # creationDate is a REAL column with Unix timestamp
351
+ cutoff_timestamp = cutoff.to_time.to_i
288
352
 
289
353
  with_database do |db|
290
354
  query = <<~SQL
@@ -292,7 +356,7 @@ module ThingsMcp
292
356
  FROM TMTask
293
357
  WHERE type = 0
294
358
  AND trashed = 0
295
- AND creationDate >= julianday('#{cutoff}')
359
+ AND creationDate >= #{cutoff_timestamp}
296
360
  ORDER BY creationDate DESC
297
361
  SQL
298
362
 
@@ -303,7 +367,18 @@ module ThingsMcp
303
367
  private
304
368
 
305
369
  def find_database_path
306
- group_containers_dir = "#{Dir.home}/Library/Group Containers"
370
+ # Use actual user's home directory, not the process owner's
371
+ actual_home = ENV["HOME"] || Dir.home
372
+ # If running as root, try to find the real user's home directory
373
+ if actual_home == "/var/root" && ENV["SUDO_USER"]
374
+ actual_home = "/Users/#{ENV["SUDO_USER"]}"
375
+ elsif actual_home == "/var/root"
376
+ # Fallback: look for the most recent user directory
377
+ user_dirs = Dir.glob("/Users/*").select { |d| File.directory?(d) && !File.basename(d).start_with?(".") }
378
+ actual_home = user_dirs.max_by { |d| File.mtime(d) } if user_dirs.any?
379
+ end
380
+
381
+ group_containers_dir = "#{actual_home}/Library/Group Containers"
307
382
 
308
383
  # Find Things-specific directories to avoid permission issues
309
384
  things_dirs = Dir.glob("#{group_containers_dir}/*").select do |dir|
@@ -311,11 +386,24 @@ module ThingsMcp
311
386
  end
312
387
 
313
388
  things_dirs.each do |things_dir|
314
- pattern = "#{things_dir}/*/*/main.sqlite"
315
- matches = Dir.glob(pattern)
389
+ # First try to find the current database (not in Backups folder)
390
+ current_pattern = "#{things_dir}/*/Things Database.thingsdatabase/main.sqlite"
391
+ current_matches = Dir.glob(current_pattern).reject { |path| path.include?("/Backups/") }
392
+
393
+ unless current_matches.empty?
394
+ return current_matches.first
395
+ end
316
396
 
317
- return matches.first unless matches.empty?
397
+ # Fallback: look for any main.sqlite but exclude backups
398
+ fallback_pattern = "#{things_dir}/*/*/main.sqlite"
399
+ fallback_matches = Dir.glob(fallback_pattern).reject { |path| path.include?("/Backups/") }
400
+
401
+ unless fallback_matches.empty?
402
+ return fallback_matches.first
403
+ end
318
404
  end
405
+
406
+ nil
319
407
  end
320
408
 
321
409
  def todo_columns
@@ -389,10 +477,10 @@ module ThingsMcp
389
477
  area: row["area"],
390
478
  tags: [], # Tags will be populated separately
391
479
  when: format_when(row["start"]),
392
- start_date: julian_to_date(row["startDate"]),
393
- deadline: julian_to_date(row["deadline"]),
394
- created: julian_to_date(row["creationDate"]),
395
- modified: julian_to_date(row["userModificationDate"]),
480
+ start_date: things_date_to_date(row["startDate"]),
481
+ deadline: things_date_to_date(row["deadline"]),
482
+ created: unix_timestamp_to_date(row["creationDate"]),
483
+ modified: unix_timestamp_to_date(row["userModificationDate"]),
396
484
  }
397
485
  end
398
486
 
@@ -405,10 +493,10 @@ module ThingsMcp
405
493
  area: row["area"],
406
494
  tags: [], # Tags will be populated separately for projects too
407
495
  when: format_when(row["start"]),
408
- start_date: julian_to_date(row["startDate"]),
409
- deadline: julian_to_date(row["deadline"]),
410
- created: julian_to_date(row["creationDate"]),
411
- modified: julian_to_date(row["userModificationDate"]),
496
+ start_date: things_date_to_date(row["startDate"]),
497
+ deadline: things_date_to_date(row["deadline"]),
498
+ created: unix_timestamp_to_date(row["creationDate"]),
499
+ modified: unix_timestamp_to_date(row["userModificationDate"]),
412
500
  }
413
501
  end
414
502
 
@@ -446,11 +534,20 @@ module ThingsMcp
446
534
  end
447
535
  end
448
536
 
449
- def julian_to_date(julian_days)
450
- return unless julian_days
537
+ def things_date_to_date(value)
538
+ return unless value
451
539
 
452
- # SQLite Julian day starts from noon on November 24, 4714 BC
453
- Date.jd(julian_days.to_i + 2400001).to_s
540
+ # Things uses custom date encoding: 128 units = 1 day (11.25 minutes per unit)
541
+ # Epoch calculated from reverse engineering known date values
542
+ days = value / UNITS_PER_DAY
543
+ (EPOCH + days).to_s
544
+ end
545
+
546
+ def unix_timestamp_to_date(value)
547
+ return unless value
548
+
549
+ # REAL columns (creationDate, userModificationDate): Unix timestamps (seconds since 1970-01-01)
550
+ Time.at(value.to_f).to_date.to_s
454
551
  end
455
552
 
456
553
  def parse_period(period)
@@ -477,6 +574,15 @@ module ThingsMcp
477
574
  # If decoding fails, return original title
478
575
  title
479
576
  end
577
+
578
+ # Helper method to consistently add checklist items and tags to todos
579
+ def add_checklist_items_and_tags(db, todos)
580
+ todos.each do |todo|
581
+ todo[:checklist_items] = get_checklist_items(db, todo[:uuid])
582
+ todo[:tags] = get_tags_for_task(db, todo[:uuid])
583
+ end
584
+ todos
585
+ end
480
586
  end
481
587
  end
482
588
  end
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require_relative "database"
5
- require_relative "url_scheme"
6
- require_relative "formatters"
4
+ require "things_mcp/database"
5
+ require "things_mcp/url_scheme"
6
+ require "things_mcp/formatters"
7
7
 
8
8
  module ThingsMcp
9
9
  # Tool call handlers for MCP server
@@ -3,10 +3,10 @@
3
3
  require "mcp"
4
4
  require "mcp/transports/stdio"
5
5
  require "logger"
6
- require_relative "tools"
7
- require_relative "handlers"
8
- require_relative "database"
9
- require_relative "url_scheme"
6
+ require "things_mcp/tools"
7
+ require "things_mcp/handlers"
8
+ require "things_mcp/database"
9
+ require "things_mcp/url_scheme"
10
10
 
11
11
  module ThingsMcp
12
12
  # MCP server implementation for Things 3 integration
@@ -95,7 +95,7 @@ module ThingsMcp
95
95
  required: tool_def[:inputSchema][:required],
96
96
  )
97
97
 
98
- define_singleton_method(:call) do |_server_context:, **arguments|
98
+ define_singleton_method(:call) do |server_context:, **arguments|
99
99
  # Convert symbol keys to string keys for consistent access
100
100
  string_arguments = arguments.transform_keys(&:to_s)
101
101
  result = ThingsMcp::Handlers.handle_tool_call(name, string_arguments)
@@ -107,12 +107,15 @@ module ThingsMcp
107
107
  },
108
108
  ])
109
109
  rescue => e
110
- MCP::Tool::Response.new([
111
- {
112
- type: "text",
113
- text: "Error: #{e.message}",
114
- },
115
- ])
110
+ # Log the full error to stderr for debugging
111
+ $stderr.puts "ERROR in tool #{name}: #{e.class}: #{e.message}"
112
+ $stderr.puts "Arguments: #{string_arguments.inspect}"
113
+ $stderr.puts "Backtrace:"
114
+ e.backtrace.each { |line| $stderr.puts " #{line}" }
115
+ $stderr.flush
116
+
117
+ # Also raise the error so MCP can handle it properly
118
+ raise e
116
119
  end
117
120
  end
118
121
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ThingsMcp
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
data/lib/things_mcp.rb CHANGED
@@ -1,12 +1,12 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "mcp"
4
- require_relative "things_mcp/server"
5
- require_relative "things_mcp/database"
6
- require_relative "things_mcp/handlers"
7
- require_relative "things_mcp/tools"
8
- require_relative "things_mcp/url_scheme"
9
- require_relative "things_mcp/formatters"
4
+ require "things_mcp/server"
5
+ require "things_mcp/database"
6
+ require "things_mcp/handlers"
7
+ require "things_mcp/tools"
8
+ require "things_mcp/url_scheme"
9
+ require "things_mcp/formatters"
10
10
 
11
11
  # ThingsMcp provides a Model Context Protocol (MCP) server for Things 3
12
12
  #
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: things-mcp
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hakan Ensari