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.
Files changed (65) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +15 -0
  4. data/Gemfile +17 -0
  5. data/Gemfile.lock +143 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +112 -0
  8. data/Rakefile +53 -0
  9. data/lib/tickrb/auth.rb +106 -0
  10. data/lib/tickrb/client.rb +144 -0
  11. data/lib/tickrb/mcp_server.rb +445 -0
  12. data/lib/tickrb/token_store.rb +29 -0
  13. data/lib/tickrb/version.rb +6 -0
  14. data/lib/tickrb.rb +37 -0
  15. data/sorbet/config +4 -0
  16. data/sorbet/rbi/gems/.gitattributes +1 -0
  17. data/sorbet/rbi/gems/ast@2.4.3.rbi +585 -0
  18. data/sorbet/rbi/gems/benchmark@0.4.1.rbi +619 -0
  19. data/sorbet/rbi/gems/diff-lcs@1.6.2.rbi +1134 -0
  20. data/sorbet/rbi/gems/dotenv@3.1.8.rbi +295 -0
  21. data/sorbet/rbi/gems/erubi@1.13.1.rbi +155 -0
  22. data/sorbet/rbi/gems/json@2.12.2.rbi +2051 -0
  23. data/sorbet/rbi/gems/language_server-protocol@3.17.0.5.rbi +14244 -0
  24. data/sorbet/rbi/gems/lint_roller@1.1.0.rbi +240 -0
  25. data/sorbet/rbi/gems/logger@1.7.0.rbi +963 -0
  26. data/sorbet/rbi/gems/net-http@0.6.0.rbi +4247 -0
  27. data/sorbet/rbi/gems/netrc@0.11.0.rbi +159 -0
  28. data/sorbet/rbi/gems/parallel@1.27.0.rbi +291 -0
  29. data/sorbet/rbi/gems/parser@3.3.8.0.rbi +5535 -0
  30. data/sorbet/rbi/gems/prism@1.4.0.rbi +41732 -0
  31. data/sorbet/rbi/gems/racc@1.8.1.rbi +164 -0
  32. data/sorbet/rbi/gems/rainbow@3.1.1.rbi +403 -0
  33. data/sorbet/rbi/gems/rake@13.3.0.rbi +3031 -0
  34. data/sorbet/rbi/gems/rbi@0.3.3.rbi +6742 -0
  35. data/sorbet/rbi/gems/rbs@3.9.4.rbi +6976 -0
  36. data/sorbet/rbi/gems/regexp_parser@2.10.0.rbi +3795 -0
  37. data/sorbet/rbi/gems/rexml@3.4.1.rbi +5243 -0
  38. data/sorbet/rbi/gems/rspec-core@3.13.4.rbi +11238 -0
  39. data/sorbet/rbi/gems/rspec-expectations@3.13.5.rbi +8189 -0
  40. data/sorbet/rbi/gems/rspec-mocks@3.13.5.rbi +5350 -0
  41. data/sorbet/rbi/gems/rspec-sorbet-types@0.3.0.rbi +130 -0
  42. data/sorbet/rbi/gems/rspec-support@3.13.4.rbi +1630 -0
  43. data/sorbet/rbi/gems/rspec@3.13.1.rbi +83 -0
  44. data/sorbet/rbi/gems/rubocop-ast@1.45.0.rbi +7721 -0
  45. data/sorbet/rbi/gems/rubocop-performance@1.25.0.rbi +9 -0
  46. data/sorbet/rbi/gems/rubocop@1.75.8.rbi +62104 -0
  47. data/sorbet/rbi/gems/ruby-progressbar@1.13.0.rbi +1318 -0
  48. data/sorbet/rbi/gems/spoom@1.6.3.rbi +6985 -0
  49. data/sorbet/rbi/gems/standard-custom@1.0.2.rbi +9 -0
  50. data/sorbet/rbi/gems/standard-performance@1.8.0.rbi +9 -0
  51. data/sorbet/rbi/gems/standard@1.50.0.rbi +1146 -0
  52. data/sorbet/rbi/gems/tapioca@0.16.11.rbi +3628 -0
  53. data/sorbet/rbi/gems/thor@1.3.2.rbi +4378 -0
  54. data/sorbet/rbi/gems/unicode-display_width@3.1.4.rbi +132 -0
  55. data/sorbet/rbi/gems/unicode-emoji@4.0.4.rbi +251 -0
  56. data/sorbet/rbi/gems/uri@1.0.3.rbi +2325 -0
  57. data/sorbet/rbi/gems/webrick@1.9.1.rbi +2856 -0
  58. data/sorbet/rbi/gems/yard-sorbet@0.9.0.rbi +435 -0
  59. data/sorbet/rbi/gems/yard@0.9.37.rbi +18445 -0
  60. data/sorbet/rbi/tickrb.rbi +9 -0
  61. data/sorbet/tapioca.yml +10 -0
  62. data/test_api_direct.rb +98 -0
  63. data/test_mcp_server.rb +157 -0
  64. data/tickrb.gemspec +47 -0
  65. 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
@@ -0,0 +1,6 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module Tickrb
5
+ VERSION = "0.1.0"
6
+ 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,4 @@
1
+ --dir
2
+ .
3
+ --ignore=/vendor/
4
+ --ignore=/tmp/
@@ -0,0 +1 @@
1
+ **/*.rbi linguist-generated=true