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.
- checksums.yaml +7 -0
- data/Gemfile +11 -0
- data/LICENSE +21 -0
- data/README.md +221 -0
- data/Rakefile +6 -0
- data/bin/test_connection +118 -0
- data/bin/things_mcp_server +8 -0
- data/lib/things_mcp/database.rb +500 -0
- data/lib/things_mcp/formatters.rb +152 -0
- data/lib/things_mcp/handlers.rb +257 -0
- data/lib/things_mcp/server.rb +120 -0
- data/lib/things_mcp/tools.rb +463 -0
- data/lib/things_mcp/url_scheme.rb +156 -0
- data/lib/things_mcp.rb +24 -0
- metadata +97 -0
@@ -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
|