tickrb 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/.rspec +3 -0
- data/.rubocop.yml +15 -0
- data/Gemfile +17 -0
- data/Gemfile.lock +143 -0
- data/LICENSE.txt +21 -0
- data/README.md +112 -0
- data/Rakefile +53 -0
- data/lib/tickrb/auth.rb +106 -0
- data/lib/tickrb/client.rb +144 -0
- data/lib/tickrb/mcp_server.rb +445 -0
- data/lib/tickrb/token_store.rb +29 -0
- data/lib/tickrb/version.rb +6 -0
- data/lib/tickrb.rb +37 -0
- data/sorbet/config +4 -0
- data/sorbet/rbi/gems/.gitattributes +1 -0
- data/sorbet/rbi/gems/ast@2.4.3.rbi +585 -0
- data/sorbet/rbi/gems/benchmark@0.4.1.rbi +619 -0
- data/sorbet/rbi/gems/diff-lcs@1.6.2.rbi +1134 -0
- data/sorbet/rbi/gems/dotenv@3.1.8.rbi +295 -0
- data/sorbet/rbi/gems/erubi@1.13.1.rbi +155 -0
- data/sorbet/rbi/gems/json@2.12.2.rbi +2051 -0
- data/sorbet/rbi/gems/language_server-protocol@3.17.0.5.rbi +14244 -0
- data/sorbet/rbi/gems/lint_roller@1.1.0.rbi +240 -0
- data/sorbet/rbi/gems/logger@1.7.0.rbi +963 -0
- data/sorbet/rbi/gems/net-http@0.6.0.rbi +4247 -0
- data/sorbet/rbi/gems/netrc@0.11.0.rbi +159 -0
- data/sorbet/rbi/gems/parallel@1.27.0.rbi +291 -0
- data/sorbet/rbi/gems/parser@3.3.8.0.rbi +5535 -0
- data/sorbet/rbi/gems/prism@1.4.0.rbi +41732 -0
- data/sorbet/rbi/gems/racc@1.8.1.rbi +164 -0
- data/sorbet/rbi/gems/rainbow@3.1.1.rbi +403 -0
- data/sorbet/rbi/gems/rake@13.3.0.rbi +3031 -0
- data/sorbet/rbi/gems/rbi@0.3.3.rbi +6742 -0
- data/sorbet/rbi/gems/rbs@3.9.4.rbi +6976 -0
- data/sorbet/rbi/gems/regexp_parser@2.10.0.rbi +3795 -0
- data/sorbet/rbi/gems/rexml@3.4.1.rbi +5243 -0
- data/sorbet/rbi/gems/rspec-core@3.13.4.rbi +11238 -0
- data/sorbet/rbi/gems/rspec-expectations@3.13.5.rbi +8189 -0
- data/sorbet/rbi/gems/rspec-mocks@3.13.5.rbi +5350 -0
- data/sorbet/rbi/gems/rspec-sorbet-types@0.3.0.rbi +130 -0
- data/sorbet/rbi/gems/rspec-support@3.13.4.rbi +1630 -0
- data/sorbet/rbi/gems/rspec@3.13.1.rbi +83 -0
- data/sorbet/rbi/gems/rubocop-ast@1.45.0.rbi +7721 -0
- data/sorbet/rbi/gems/rubocop-performance@1.25.0.rbi +9 -0
- data/sorbet/rbi/gems/rubocop@1.75.8.rbi +62104 -0
- data/sorbet/rbi/gems/ruby-progressbar@1.13.0.rbi +1318 -0
- data/sorbet/rbi/gems/spoom@1.6.3.rbi +6985 -0
- data/sorbet/rbi/gems/standard-custom@1.0.2.rbi +9 -0
- data/sorbet/rbi/gems/standard-performance@1.8.0.rbi +9 -0
- data/sorbet/rbi/gems/standard@1.50.0.rbi +1146 -0
- data/sorbet/rbi/gems/tapioca@0.16.11.rbi +3628 -0
- data/sorbet/rbi/gems/thor@1.3.2.rbi +4378 -0
- data/sorbet/rbi/gems/unicode-display_width@3.1.4.rbi +132 -0
- data/sorbet/rbi/gems/unicode-emoji@4.0.4.rbi +251 -0
- data/sorbet/rbi/gems/uri@1.0.3.rbi +2325 -0
- data/sorbet/rbi/gems/webrick@1.9.1.rbi +2856 -0
- data/sorbet/rbi/gems/yard-sorbet@0.9.0.rbi +435 -0
- data/sorbet/rbi/gems/yard@0.9.37.rbi +18445 -0
- data/sorbet/rbi/tickrb.rbi +9 -0
- data/sorbet/tapioca.yml +10 -0
- data/test_api_direct.rb +98 -0
- data/test_mcp_server.rb +157 -0
- data/tickrb.gemspec +47 -0
- metadata +278 -0
@@ -0,0 +1,445 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "json"
|
5
|
+
require "sorbet-runtime"
|
6
|
+
|
7
|
+
require_relative "client"
|
8
|
+
require_relative "version"
|
9
|
+
|
10
|
+
module Tickrb
|
11
|
+
class Error < StandardError; end
|
12
|
+
|
13
|
+
class McpServer
|
14
|
+
extend T::Sig
|
15
|
+
|
16
|
+
JSONRPC = "2.0"
|
17
|
+
PROTOCOL_VERSION = "2024-11-05"
|
18
|
+
SERVER_INFO = {
|
19
|
+
name: "tickrb-mcp-server",
|
20
|
+
version: VERSION
|
21
|
+
}
|
22
|
+
|
23
|
+
class << self
|
24
|
+
def start
|
25
|
+
new.start
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
sig { void }
|
30
|
+
def initialize
|
31
|
+
@tools = {}
|
32
|
+
@resources = {}
|
33
|
+
@client = nil
|
34
|
+
|
35
|
+
register_default_tools
|
36
|
+
register_ticktick_tools
|
37
|
+
end
|
38
|
+
|
39
|
+
sig { void }
|
40
|
+
def start
|
41
|
+
loop do
|
42
|
+
input = $stdin.gets
|
43
|
+
break if input.nil?
|
44
|
+
|
45
|
+
begin
|
46
|
+
request = JSON.parse(input.strip)
|
47
|
+
response = handle_request(request)
|
48
|
+
puts response.to_json if response
|
49
|
+
$stdout.flush
|
50
|
+
rescue JSON::ParserError => e
|
51
|
+
error_response = {
|
52
|
+
jsonrpc: JSONRPC,
|
53
|
+
error: {
|
54
|
+
code: -32700,
|
55
|
+
message: "Parse error",
|
56
|
+
data: e.message
|
57
|
+
},
|
58
|
+
id: nil
|
59
|
+
}
|
60
|
+
puts error_response.to_json
|
61
|
+
rescue => e
|
62
|
+
error_response = {
|
63
|
+
jsonrpc: JSONRPC,
|
64
|
+
error: {
|
65
|
+
code: -32603,
|
66
|
+
message: "Internal error",
|
67
|
+
data: e.message
|
68
|
+
},
|
69
|
+
id: request&.dig("id")
|
70
|
+
}
|
71
|
+
puts error_response.to_json
|
72
|
+
end
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
private
|
77
|
+
|
78
|
+
sig { params(request: T::Hash[String, T.untyped]).returns(T.nilable(T::Hash[String, T.untyped])) }
|
79
|
+
def handle_request(request)
|
80
|
+
method = request["method"]
|
81
|
+
params = request["params"] || {}
|
82
|
+
id = request["id"]
|
83
|
+
|
84
|
+
case method
|
85
|
+
when "initialize"
|
86
|
+
handle_initialize(params, id)
|
87
|
+
when "tools/list"
|
88
|
+
handle_list_tools(id)
|
89
|
+
when "tools/call"
|
90
|
+
handle_call_tool(params, id)
|
91
|
+
when "resources/list"
|
92
|
+
handle_list_resources(id)
|
93
|
+
when "resources/read"
|
94
|
+
handle_read_resource(params, id)
|
95
|
+
else
|
96
|
+
{
|
97
|
+
jsonrpc: JSONRPC,
|
98
|
+
error: {
|
99
|
+
code: -32601,
|
100
|
+
message: "Method not found"
|
101
|
+
},
|
102
|
+
id: id
|
103
|
+
}
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
sig { params(params: T::Hash[String, T.untyped], id: T.untyped).returns(T::Hash[String, T.untyped]) }
|
108
|
+
def handle_initialize(params, id)
|
109
|
+
{
|
110
|
+
jsonrpc: JSONRPC,
|
111
|
+
result: {
|
112
|
+
protocolVersion: PROTOCOL_VERSION, # rubocop:disable Naming/VariableName
|
113
|
+
capabilities: {
|
114
|
+
tools: {},
|
115
|
+
resources: {}
|
116
|
+
},
|
117
|
+
serverInfo: SERVER_INFO # rubocop:disable Naming/VariableName
|
118
|
+
},
|
119
|
+
id: id
|
120
|
+
}
|
121
|
+
end
|
122
|
+
|
123
|
+
sig { params(id: T.untyped).returns(T::Hash[String, T.untyped]) }
|
124
|
+
def handle_list_tools(id)
|
125
|
+
{
|
126
|
+
jsonrpc: JSONRPC,
|
127
|
+
result: {
|
128
|
+
tools: @tools.values
|
129
|
+
},
|
130
|
+
id: id
|
131
|
+
}
|
132
|
+
end
|
133
|
+
|
134
|
+
sig { params(params: T::Hash[String, T.untyped], id: T.untyped).returns(T::Hash[String, T.untyped]) }
|
135
|
+
def handle_call_tool(params, id)
|
136
|
+
tool_name = params["name"]
|
137
|
+
arguments = params["arguments"] || {}
|
138
|
+
|
139
|
+
unless @tools.key?(tool_name)
|
140
|
+
return {
|
141
|
+
jsonrpc: JSONRPC,
|
142
|
+
error: {
|
143
|
+
code: -32602,
|
144
|
+
message: "Tool not found: #{tool_name}"
|
145
|
+
},
|
146
|
+
id: id
|
147
|
+
}
|
148
|
+
end
|
149
|
+
|
150
|
+
tool = @tools[tool_name]
|
151
|
+
result = tool[:handler].call(arguments)
|
152
|
+
|
153
|
+
# Handle structured responses vs string responses
|
154
|
+
content = if result.is_a?(Hash)
|
155
|
+
[
|
156
|
+
{
|
157
|
+
type: "text",
|
158
|
+
text: result.to_json
|
159
|
+
}
|
160
|
+
]
|
161
|
+
else
|
162
|
+
[
|
163
|
+
{
|
164
|
+
type: "text",
|
165
|
+
text: result
|
166
|
+
}
|
167
|
+
]
|
168
|
+
end
|
169
|
+
|
170
|
+
{
|
171
|
+
jsonrpc: "2.0",
|
172
|
+
result: {
|
173
|
+
content: content
|
174
|
+
},
|
175
|
+
id: id
|
176
|
+
}
|
177
|
+
end
|
178
|
+
|
179
|
+
sig { params(id: T.untyped).returns(T::Hash[String, T.untyped]) }
|
180
|
+
def handle_list_resources(id)
|
181
|
+
{
|
182
|
+
jsonrpc: JSONRPC,
|
183
|
+
result: {
|
184
|
+
resources: @resources.values
|
185
|
+
},
|
186
|
+
id: id
|
187
|
+
}
|
188
|
+
end
|
189
|
+
|
190
|
+
sig { params(params: T::Hash[String, T.untyped], id: T.untyped).returns(T::Hash[String, T.untyped]) }
|
191
|
+
def handle_read_resource(params, id)
|
192
|
+
uri = params["uri"]
|
193
|
+
|
194
|
+
resource = @resources.values.find { |r| r[:uri] == uri }
|
195
|
+
unless resource
|
196
|
+
return {
|
197
|
+
jsonrpc: JSONRPC,
|
198
|
+
error: {
|
199
|
+
code: -32602,
|
200
|
+
message: "Resource not found: #{uri}"
|
201
|
+
},
|
202
|
+
id: id
|
203
|
+
}
|
204
|
+
end
|
205
|
+
|
206
|
+
content = resource[:handler].call
|
207
|
+
{
|
208
|
+
jsonrpc: JSONRPC,
|
209
|
+
result: {
|
210
|
+
contents: [
|
211
|
+
{
|
212
|
+
uri: uri,
|
213
|
+
mimeType: resource[:mimeType] || "text/plain", # rubocop:disable Naming/VariableName
|
214
|
+
text: content
|
215
|
+
}
|
216
|
+
]
|
217
|
+
},
|
218
|
+
id: id
|
219
|
+
}
|
220
|
+
end
|
221
|
+
|
222
|
+
sig { void }
|
223
|
+
def register_default_tools
|
224
|
+
register_tool(
|
225
|
+
name: "ping",
|
226
|
+
description: "Simple ping tool to test server connectivity",
|
227
|
+
inputSchema: { # rubocop:disable Naming/VariableName
|
228
|
+
type: "object",
|
229
|
+
properties: {
|
230
|
+
message: {
|
231
|
+
type: "string",
|
232
|
+
description: "Message to echo back"
|
233
|
+
}
|
234
|
+
}
|
235
|
+
}
|
236
|
+
) do |args|
|
237
|
+
{
|
238
|
+
message: "Pong! #{args["message"] || "Hello from TickRb MCP Server"}"
|
239
|
+
}
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
sig { void }
|
244
|
+
def register_ticktick_tools
|
245
|
+
register_tool(
|
246
|
+
name: "list_tasks",
|
247
|
+
description: "Get all tasks from TickTick",
|
248
|
+
inputSchema: { # rubocop:disable Naming/VariableName
|
249
|
+
type: "object",
|
250
|
+
properties: {}
|
251
|
+
}
|
252
|
+
) do |args|
|
253
|
+
client = get_client
|
254
|
+
tasks = client.get_tasks
|
255
|
+
{
|
256
|
+
success: true,
|
257
|
+
tasks: tasks.map do |task|
|
258
|
+
{
|
259
|
+
id: task["id"],
|
260
|
+
title: task["title"],
|
261
|
+
project_id: task["projectId"],
|
262
|
+
due_date: task["dueDate"],
|
263
|
+
description: task["desc"],
|
264
|
+
status: task["status"] || "open"
|
265
|
+
}
|
266
|
+
end,
|
267
|
+
count: tasks.length
|
268
|
+
}
|
269
|
+
rescue => e
|
270
|
+
{
|
271
|
+
success: false,
|
272
|
+
error: e.message,
|
273
|
+
tasks: [],
|
274
|
+
count: 0
|
275
|
+
}
|
276
|
+
end
|
277
|
+
|
278
|
+
register_tool(
|
279
|
+
name: "create_task",
|
280
|
+
description: "Create a new task in TickTick",
|
281
|
+
inputSchema: { # rubocop:disable Naming/VariableName
|
282
|
+
type: "object",
|
283
|
+
properties: {
|
284
|
+
title: {
|
285
|
+
type: "string",
|
286
|
+
description: "Task title"
|
287
|
+
},
|
288
|
+
content: {
|
289
|
+
type: "string",
|
290
|
+
description: "Task description/content"
|
291
|
+
},
|
292
|
+
project_id: {
|
293
|
+
type: "string",
|
294
|
+
description: "Project ID to add task to"
|
295
|
+
}
|
296
|
+
},
|
297
|
+
required: ["title"]
|
298
|
+
}
|
299
|
+
) do |args|
|
300
|
+
client = get_client
|
301
|
+
task = client.create_task(
|
302
|
+
title: args["title"],
|
303
|
+
content: args["content"],
|
304
|
+
project_id: args["project_id"]
|
305
|
+
)
|
306
|
+
{
|
307
|
+
success: true,
|
308
|
+
task: {
|
309
|
+
id: task["id"],
|
310
|
+
title: task["title"],
|
311
|
+
content: task["content"],
|
312
|
+
project_id: task["projectId"]
|
313
|
+
}
|
314
|
+
}
|
315
|
+
rescue => e
|
316
|
+
{
|
317
|
+
success: false,
|
318
|
+
error: e.message,
|
319
|
+
task: nil
|
320
|
+
}
|
321
|
+
end
|
322
|
+
|
323
|
+
register_tool(
|
324
|
+
name: "complete_task",
|
325
|
+
description: "Mark a task as completed in TickTick",
|
326
|
+
inputSchema: { # rubocop:disable Naming/VariableName
|
327
|
+
type: "object",
|
328
|
+
properties: {
|
329
|
+
task_id: {
|
330
|
+
type: "string",
|
331
|
+
description: "ID of the task to complete"
|
332
|
+
},
|
333
|
+
project_id: {
|
334
|
+
type: "string",
|
335
|
+
description: "ID of the project containing the task"
|
336
|
+
}
|
337
|
+
},
|
338
|
+
required: ["task_id", "project_id"]
|
339
|
+
}
|
340
|
+
) do |args|
|
341
|
+
client = get_client
|
342
|
+
client.complete_task(args["task_id"], args["project_id"])
|
343
|
+
{
|
344
|
+
success: true,
|
345
|
+
message: "Task marked as completed",
|
346
|
+
task_id: args["task_id"]
|
347
|
+
}
|
348
|
+
rescue => e
|
349
|
+
{
|
350
|
+
success: false,
|
351
|
+
error: e.message,
|
352
|
+
task_id: args["task_id"]
|
353
|
+
}
|
354
|
+
end
|
355
|
+
|
356
|
+
register_tool(
|
357
|
+
name: "delete_task",
|
358
|
+
description: "Delete a task in TickTick",
|
359
|
+
inputSchema: { # rubocop:disable Naming/VariableName
|
360
|
+
type: "object",
|
361
|
+
properties: {
|
362
|
+
task_id: {
|
363
|
+
type: "string",
|
364
|
+
description: "ID of the task to delete"
|
365
|
+
},
|
366
|
+
project_id: {
|
367
|
+
type: "string",
|
368
|
+
description: "ID of the project containing the task"
|
369
|
+
}
|
370
|
+
},
|
371
|
+
required: ["task_id", "project_id"]
|
372
|
+
}
|
373
|
+
) do |args|
|
374
|
+
client = get_client
|
375
|
+
client.delete_task(args["task_id"], args["project_id"])
|
376
|
+
{
|
377
|
+
success: true,
|
378
|
+
message: "Task deleted successfully",
|
379
|
+
task_id: args["task_id"]
|
380
|
+
}
|
381
|
+
rescue => e
|
382
|
+
{
|
383
|
+
success: false,
|
384
|
+
error: e.message,
|
385
|
+
task_id: args["task_id"]
|
386
|
+
}
|
387
|
+
end
|
388
|
+
|
389
|
+
register_tool(
|
390
|
+
name: "list_projects",
|
391
|
+
description: "Get all projects from TickTick",
|
392
|
+
inputSchema: { # rubocop:disable Naming/VariableName
|
393
|
+
type: "object",
|
394
|
+
properties: {}
|
395
|
+
}
|
396
|
+
) do |args|
|
397
|
+
client = get_client
|
398
|
+
projects = client.get_projects
|
399
|
+
{
|
400
|
+
success: true,
|
401
|
+
projects: projects.map do |project|
|
402
|
+
{
|
403
|
+
id: project["id"],
|
404
|
+
name: project["name"]
|
405
|
+
}
|
406
|
+
end,
|
407
|
+
count: projects.length
|
408
|
+
}
|
409
|
+
rescue => e
|
410
|
+
{
|
411
|
+
success: false,
|
412
|
+
error: e.message,
|
413
|
+
projects: [],
|
414
|
+
count: 0
|
415
|
+
}
|
416
|
+
end
|
417
|
+
end
|
418
|
+
|
419
|
+
sig { returns(Client) }
|
420
|
+
def get_client
|
421
|
+
@client ||= Client.new
|
422
|
+
end
|
423
|
+
|
424
|
+
sig { params(name: String, description: String, inputSchema: T::Hash[String, T.untyped], block: T.proc.params(args: T::Hash[String, T.untyped]).returns(T::Hash[T.untyped, T.untyped])).void }
|
425
|
+
def register_tool(name:, description:, inputSchema:, &block) # rubocop:disable Naming/VariableName
|
426
|
+
@tools[name] = {
|
427
|
+
name: name,
|
428
|
+
description: description,
|
429
|
+
inputSchema: inputSchema, # rubocop:disable Naming/VariableName
|
430
|
+
handler: block
|
431
|
+
}
|
432
|
+
end
|
433
|
+
|
434
|
+
sig { params(name: String, description: String, uri: String, mimeType: T.nilable(String), block: T.proc.returns(String)).void }
|
435
|
+
def register_resource(name:, description:, uri:, mimeType: nil, &block) # rubocop:disable Naming/VariableName
|
436
|
+
@resources[name] = {
|
437
|
+
name: name,
|
438
|
+
description: description,
|
439
|
+
uri: uri,
|
440
|
+
mimeType: mimeType, # rubocop:disable Naming/VariableName
|
441
|
+
handler: block
|
442
|
+
}
|
443
|
+
end
|
444
|
+
end
|
445
|
+
end
|
@@ -0,0 +1,29 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "json"
|
5
|
+
|
6
|
+
module Tickrb
|
7
|
+
class TokenStore
|
8
|
+
def self.store_token(token_info)
|
9
|
+
# TODO: make this respect some standard?
|
10
|
+
config_dir = File.expand_path("~/.config/tickrb")
|
11
|
+
Dir.mkdir(config_dir) unless Dir.exist?(config_dir)
|
12
|
+
|
13
|
+
File.write(File.join(config_dir, "token.json"), {
|
14
|
+
access_token: token_info["access_token"],
|
15
|
+
expires_at: Time.now.utc + (token_info["expires_in"] - 60)
|
16
|
+
}.to_json)
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.load_token
|
20
|
+
token_file = File.expand_path("~/.config/tickrb/token.json")
|
21
|
+
return nil unless File.exist?(token_file)
|
22
|
+
|
23
|
+
data = JSON.parse(File.read(token_file))
|
24
|
+
return nil if Time.now.utc > Time.parse(data["expires_at"]).utc
|
25
|
+
|
26
|
+
data["access_token"]
|
27
|
+
end
|
28
|
+
end
|
29
|
+
end
|
data/lib/tickrb.rb
ADDED
@@ -0,0 +1,37 @@
|
|
1
|
+
# typed: true
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "dotenv/load"
|
5
|
+
require "sorbet-runtime"
|
6
|
+
|
7
|
+
require_relative "tickrb/version"
|
8
|
+
require_relative "tickrb/auth"
|
9
|
+
require_relative "tickrb/token_store"
|
10
|
+
require_relative "tickrb/client"
|
11
|
+
require_relative "tickrb/mcp_server"
|
12
|
+
|
13
|
+
module Tickrb
|
14
|
+
extend T::Sig
|
15
|
+
|
16
|
+
class Error < StandardError; end
|
17
|
+
|
18
|
+
class << self
|
19
|
+
def mcp_server(options = {})
|
20
|
+
auth(options)
|
21
|
+
|
22
|
+
McpServer.start
|
23
|
+
end
|
24
|
+
|
25
|
+
def auth(options = {})
|
26
|
+
existing_token = TokenStore.load_token
|
27
|
+
|
28
|
+
unless existing_token
|
29
|
+
Auth.run(
|
30
|
+
client_id: options[:client_id],
|
31
|
+
client_secret: options[:client_secret],
|
32
|
+
redirect_uri: options[:redirect_uri]
|
33
|
+
)
|
34
|
+
end
|
35
|
+
end
|
36
|
+
end
|
37
|
+
end
|
data/sorbet/config
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
**/*.rbi linguist-generated=true
|