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 +4 -4
- data/bin/test_connection +2 -2
- data/bin/things_mcp_server +3 -2
- data/lib/things_mcp/database.rb +130 -24
- data/lib/things_mcp/handlers.rb +3 -3
- data/lib/things_mcp/server.rb +14 -11
- data/lib/things_mcp/version.rb +1 -1
- data/lib/things_mcp.rb +6 -6
- metadata +1 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: c04d398fe0ae4d889f3b1283f170b7ea16d89d3aadb15c0729f02224051fb40c
|
4
|
+
data.tar.gz: 63edc670dfefbf746f0397a3a41e95e81ae977237421057e89564b4b77ce998b
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 749a56ffef198d8bfabf98212faa51e8d47f8b79280a09899910a49938ea20391ebe08e8a5387ef593ec3095bea9aa7d138ca7294058d3b995ed3f4385fc0ee0
|
7
|
+
data.tar.gz: 570aeb516c134b4de385a25b672677976879dcccab000be09ba697568de5ff03864ea328a695ba03212c49b28352f5be1e820402f2ed172483aba476df9a1eca
|
data/bin/test_connection
CHANGED
data/bin/things_mcp_server
CHANGED
@@ -1,8 +1,9 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
# frozen_string_literal: true
|
3
3
|
|
4
|
-
|
5
|
-
|
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
|
data/lib/things_mcp/database.rb
CHANGED
@@ -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
|
-
|
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
|
-
|
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
|
-
|
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 >=
|
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
|
-
|
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
|
-
|
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 >=
|
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
|
-
|
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
|
-
|
315
|
-
|
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
|
-
|
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:
|
393
|
-
deadline:
|
394
|
-
created:
|
395
|
-
modified:
|
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:
|
409
|
-
deadline:
|
410
|
-
created:
|
411
|
-
modified:
|
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
|
450
|
-
return unless
|
537
|
+
def things_date_to_date(value)
|
538
|
+
return unless value
|
451
539
|
|
452
|
-
#
|
453
|
-
|
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
|
data/lib/things_mcp/handlers.rb
CHANGED
@@ -1,9 +1,9 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "json"
|
4
|
-
|
5
|
-
|
6
|
-
|
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
|
data/lib/things_mcp/server.rb
CHANGED
@@ -3,10 +3,10 @@
|
|
3
3
|
require "mcp"
|
4
4
|
require "mcp/transports/stdio"
|
5
5
|
require "logger"
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
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 |
|
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
|
-
|
111
|
-
|
112
|
-
|
113
|
-
|
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
|
data/lib/things_mcp/version.rb
CHANGED
data/lib/things_mcp.rb
CHANGED
@@ -1,12 +1,12 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "mcp"
|
4
|
-
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
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
|
#
|