ticktick-mcp-server 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cf9d114f051a0ddae817d5e1cc84a622bfbb057ee97cd8a1c68008bba0929833
4
- data.tar.gz: 2678932d002e8c15e769fecb194c436fb69f0e7033dedd66fe8e10ccd39ed6b7
3
+ metadata.gz: 57893a42594197579a7d035f6e2b0f77ccb0de47a8b5eea857ec6d1d8b1f078d
4
+ data.tar.gz: 11c46a9168ad7edaf65894b347d6669909aa539a201f601b032ff6d40cb7ac3c
5
5
  SHA512:
6
- metadata.gz: 4610d280cf0ff4c90fe73542abdb1b2960c3ab8ca0c79c87c7e3ad5cdb6cd03518b51a96934a0c4c0f24143757a1a85fea2667eb8032fd41dae587fb701e44be
7
- data.tar.gz: 8d972718c6c3f0d12fc028f0a61bccb5c3083e509e35863e9a9cc46877dd9e77832b64bb108c28d73dff8c9ffff7e6be6c2634b169aaee7dee622380a290ff99
6
+ metadata.gz: 3e254267fa319f2d444826e38a56b5921b1dfaa663856d0430ea49e6efb228ad190dd616fe4019e20a7445e511bbf67a73c640925492dbcf385a009ba11a45ea
7
+ data.tar.gz: ad77646c2214318b9c8a509de044167203c92db95d33e18c84a3a9b5c5b52456ebd2a5d9a1e7d8672614c076e42dba5aeb953c162c73c5b23cd1268dbfb24104
data/.rubocop.yml CHANGED
@@ -13,6 +13,7 @@ Style/Documentation:
13
13
  Metrics/BlockLength:
14
14
  Exclude:
15
15
  - "spec/**/*_spec.rb"
16
+ - "*.gemspec"
16
17
 
17
18
  Metrics/ParameterLists:
18
19
  Exclude:
data/Dockerfile ADDED
@@ -0,0 +1,15 @@
1
+ # syntax=docker/dockerfile:1
2
+ FROM ruby:3.4-alpine
3
+
4
+ RUN apk add --no-cache \
5
+ build-base \
6
+ ca-certificates \
7
+ tzdata
8
+
9
+ RUN addgroup -S mcp && adduser -S mcp -G mcp
10
+
11
+ RUN gem install ticktick-mcp-server --no-document
12
+
13
+ USER mcp
14
+
15
+ CMD ["ticktick-mcp-server"]
data/Dockerfile.dev ADDED
@@ -0,0 +1,8 @@
1
+ FROM ruby:3.4-alpine
2
+
3
+ RUN apk add --no-cache build-base ca-certificates tzdata
4
+
5
+ COPY ticktick-mcp-server-*.gem /tmp/
6
+ RUN gem install /tmp/ticktick-mcp-server-*.gem --no-document
7
+
8
+ CMD ["ticktick-mcp-server"]
data/README.md CHANGED
@@ -4,12 +4,34 @@ A Ruby gem that exposes the [TickTick Open API](https://developer.ticktick.com/)
4
4
 
5
5
  ## Requirements
6
6
 
7
- - Ruby >= 3.1.0
8
7
  - A TickTick account with an Open API access token
8
+ - One of the following:
9
+ - Docker (recommended — no Ruby required)
10
+ - Ruby >= 3.1.0
9
11
 
10
12
  ## Installation
11
13
 
12
- Clone the repository and install the gem locally:
14
+ ### Option 1: Docker (recommended)
15
+
16
+ No Ruby installation required. Pull the pre-built image from GitHub Container Registry:
17
+
18
+ ```bash
19
+ docker pull ghcr.io/j-o-lantern0422/ticktick-mcp-server:latest
20
+ ```
21
+
22
+ Or build locally from the repository:
23
+
24
+ ```bash
25
+ docker build -t ticktick-mcp-server https://github.com/j-o-lantern0422/ticktick-mcp-server.git#main
26
+ ```
27
+
28
+ ### Option 2: RubyGems
29
+
30
+ ```bash
31
+ gem install ticktick-mcp-server
32
+ ```
33
+
34
+ ### Option 3: Clone the repository
13
35
 
14
36
  ```bash
15
37
  git clone https://github.com/j-o-lantern0422/ticktick-mcp-server
@@ -26,11 +48,89 @@ Set the following environment variable with your TickTick Open API access token:
26
48
  TICKTICK_ACCESS_TOKEN=your_access_token_here
27
49
  ```
28
50
 
29
- You can obtain an access token from the TickTick Open API developer settings.
51
+ ### Getting an Access Token
52
+
53
+ Use the built-in `ticktick-auth` tool to obtain a token via OAuth2.
54
+
55
+ #### Step 1: Register your application
56
+
57
+ Go to the [TickTick Developer Center](https://developer.ticktick.com/) and create an OAuth2 application.
58
+ Set the redirect URI to:
59
+
60
+ ```
61
+ http://localhost:8585/callback
62
+ ```
63
+
64
+ #### Step 2: Run the authentication tool
65
+
66
+ **With Docker:**
67
+
68
+ ```bash
69
+ docker run --rm -p 8585:8585 ghcr.io/j-o-lantern0422/ticktick-mcp-server:latest \
70
+ ticktick-auth --client-id=YOUR_CLIENT_ID --client-secret=YOUR_CLIENT_SECRET
71
+ ```
72
+
73
+ **With the gem installed locally:**
74
+
75
+ ```bash
76
+ ticktick-auth --client-id=YOUR_CLIENT_ID --client-secret=YOUR_CLIENT_SECRET
77
+ ```
78
+
79
+ You can also pass credentials via environment variables instead of flags:
80
+
81
+ ```bash
82
+ export TICKTICK_CLIENT_ID=YOUR_CLIENT_ID
83
+ export TICKTICK_CLIENT_SECRET=YOUR_CLIENT_SECRET
84
+ ticktick-auth
85
+ ```
86
+
87
+ > **Note:** The default callback port is `8585`. Use `--port PORT` (or `TICKTICK_AUTH_PORT`) to change it.
88
+ > If you change the port, update the redirect URI in the developer settings accordingly.
89
+
90
+ #### Step 3: Authorize in the browser
91
+
92
+ The tool will print an authorization URL. Open it in your browser and approve the request.
93
+
94
+ #### Step 4: Copy the token
95
+
96
+ After authorization, the token is printed to stdout:
97
+
98
+ ```
99
+ TICKTICK_ACCESS_TOKEN=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
100
+ ```
101
+
102
+ Copy this value and use it as your `TICKTICK_ACCESS_TOKEN`.
30
103
 
31
104
  ## Usage
32
105
 
33
- ### Claude Desktop Integration
106
+ ### Running with Docker
107
+
108
+ Add the following to your `claude_desktop_config.json`:
109
+
110
+ ```json
111
+ {
112
+ "mcpServers": {
113
+ "ticktick": {
114
+ "command": "docker",
115
+ "args": [
116
+ "run",
117
+ "--rm",
118
+ "-i",
119
+ "-e",
120
+ "TICKTICK_ACCESS_TOKEN=your_access_token_here",
121
+ "ghcr.io/j-o-lantern0422/ticktick-mcp-server:latest"
122
+ ]
123
+ }
124
+ }
125
+ }
126
+ ```
127
+
128
+ > **Note:**
129
+ > - `-i` is required for STDIO communication between Claude Desktop and the MCP server.
130
+ > - Do **not** use `-t` (pseudo-tty). It causes CR/LF conversion that breaks the JSON-RPC protocol.
131
+ > - `--rm` automatically removes the container when the session ends.
132
+
133
+ ### Claude Desktop Integration (gem installed locally)
34
134
 
35
135
  Add the following to your `claude_desktop_config.json`:
36
136
 
data/exe/ticktick-auth ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ $stdout.sync = true
5
+
6
+ require "optparse"
7
+ require "ticktick/auth/oauth_flow"
8
+
9
+ options = {
10
+ client_id: ENV["TICKTICK_CLIENT_ID"],
11
+ client_secret: ENV["TICKTICK_CLIENT_SECRET"],
12
+ port: (ENV["TICKTICK_AUTH_PORT"] || 8585).to_i
13
+ }
14
+
15
+ OptionParser.new do |opts|
16
+ opts.banner = "Usage: ticktick-auth [options]"
17
+
18
+ opts.on("--client-id ID", "TickTick OAuth2 client ID") { |v| options[:client_id] = v }
19
+ opts.on("--client-secret SECRET", "TickTick OAuth2 client secret") { |v| options[:client_secret] = v }
20
+ opts.on("--port PORT", Integer, "Callback server port (default: 8585)") { |v| options[:port] = v }
21
+ opts.on("-h", "--help", "Show this help") do
22
+ puts opts
23
+ exit 0
24
+ end
25
+ end.parse!
26
+
27
+ errors = []
28
+ errors << "client_id is required (--client-id or TICKTICK_CLIENT_ID)" unless options[:client_id]
29
+ errors << "client_secret is required (--client-secret or TICKTICK_CLIENT_SECRET)" unless options[:client_secret]
30
+
31
+ unless errors.empty?
32
+ errors.each { |e| warn "Error: #{e}" }
33
+ exit 1
34
+ end
35
+
36
+ begin
37
+ flow = Ticktick::Auth::OauthFlow.new(
38
+ client_id: options[:client_id],
39
+ client_secret: options[:client_secret],
40
+ port: options[:port]
41
+ )
42
+ token = flow.run
43
+ puts "TICKTICK_ACCESS_TOKEN=#{token}"
44
+ rescue Ticktick::Auth::OauthFlow::TimeoutError => e
45
+ warn "Error: #{e.message}"
46
+ exit 1
47
+ rescue Ticktick::Auth::OauthFlow::DeniedError => e
48
+ warn "Error: #{e.message}"
49
+ exit 1
50
+ rescue Ticktick::Auth::OauthFlow::NetworkError => e
51
+ warn "Error: #{e.message}"
52
+ exit 1
53
+ rescue Ticktick::Auth::OauthFlow::Error => e
54
+ warn "Error: #{e.message}"
55
+ exit 1
56
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webrick"
4
+
5
+ module Ticktick
6
+ module Auth
7
+ class CallbackServer
8
+ CALLBACK_PATH = "/callback"
9
+ SUCCESS_HTML = <<~HTML
10
+ <!DOCTYPE html>
11
+ <html>
12
+ <head><meta charset="utf-8"><title>TickTick Authorization</title></head>
13
+ <body>
14
+ <h1>Authorization successful!</h1>
15
+ <p>You can close this browser tab and return to the terminal.</p>
16
+ </body>
17
+ </html>
18
+ HTML
19
+ ERROR_HTML = <<~HTML
20
+ <!DOCTYPE html>
21
+ <html>
22
+ <head><meta charset="utf-8"><title>TickTick Authorization</title></head>
23
+ <body>
24
+ <h1>Authorization failed</h1>
25
+ <p>Access was denied. Please try again.</p>
26
+ </body>
27
+ </html>
28
+ HTML
29
+
30
+ def initialize(port:)
31
+ @port = port
32
+ @queue = Queue.new
33
+ end
34
+
35
+ def wait_for_code(timeout: 300)
36
+ server = build_server
37
+ server_thread = Thread.new { server.start }
38
+
39
+ result = poll_for_result(timeout)
40
+ server.shutdown
41
+ server_thread.join(5)
42
+ result
43
+ end
44
+
45
+ private
46
+
47
+ def build_server
48
+ logger = WEBrick::Log.new(nil, WEBrick::Log::FATAL)
49
+ access_log = [[nil, ""]]
50
+
51
+ server = WEBrick::HTTPServer.new(
52
+ Port: @port,
53
+ Logger: logger,
54
+ AccessLog: access_log
55
+ )
56
+ server.mount_proc(CALLBACK_PATH) { |req, res| handle_callback(req, res) }
57
+ server
58
+ end
59
+
60
+ def handle_callback(req, res)
61
+ code = req.query["code"]
62
+ error = req.query["error"]
63
+ state = req.query["state"]
64
+
65
+ if code
66
+ @queue.push({ code: code, state: state })
67
+ write_response(res, SUCCESS_HTML)
68
+ else
69
+ @queue.push({ error: error || "unknown_error" })
70
+ write_response(res, ERROR_HTML)
71
+ end
72
+ end
73
+
74
+ def write_response(res, html)
75
+ res.status = 200
76
+ res.content_type = "text/html; charset=utf-8"
77
+ res.body = html
78
+ end
79
+
80
+ def poll_for_result(timeout)
81
+ deadline = Time.now + timeout
82
+ loop do
83
+ return @queue.pop(true) unless @queue.empty?
84
+ return nil if Time.now >= deadline
85
+
86
+ sleep 0.1
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+ require "securerandom"
6
+ require "base64"
7
+ require_relative "callback_server"
8
+
9
+ module Ticktick
10
+ module Auth
11
+ class OauthFlow
12
+ AUTHORIZE_URL = "https://ticktick.com/oauth/authorize"
13
+ TOKEN_URL = "https://ticktick.com/oauth/token"
14
+ SCOPE = "tasks:read tasks:write"
15
+
16
+ Error = Class.new(StandardError)
17
+ TimeoutError = Class.new(Error)
18
+ DeniedError = Class.new(Error)
19
+ NetworkError = Class.new(Error)
20
+
21
+ def initialize(client_id:, client_secret:, port: 8585)
22
+ @client_id = client_id
23
+ @client_secret = client_secret
24
+ @port = port
25
+ end
26
+
27
+ def run
28
+ state = SecureRandom.hex(16)
29
+ redirect_uri = "http://localhost:#{@port}/callback"
30
+
31
+ puts build_authorize_message(state, redirect_uri)
32
+
33
+ server = CallbackServer.new(port: @port)
34
+ result = server.wait_for_code(timeout: 300)
35
+
36
+ raise TimeoutError, "Timed out waiting for authorization. Please try again." if result.nil?
37
+ raise DeniedError, "Authorization was denied: #{result[:error]}" if result[:error]
38
+ raise Error, "State mismatch. Possible CSRF attack." if result[:state] != state
39
+
40
+ exchange_code_for_token(result[:code], redirect_uri)
41
+ end
42
+
43
+ private
44
+
45
+ def build_authorize_message(state, redirect_uri)
46
+ url = build_authorize_url(state, redirect_uri)
47
+
48
+ <<~MSG
49
+ Open the following URL in your browser to authorize TickTick:
50
+
51
+ #{url}
52
+
53
+ Waiting for authorization (timeout: 5 minutes)...
54
+ MSG
55
+ end
56
+
57
+ def build_authorize_url(state, redirect_uri)
58
+ params = URI.encode_www_form(
59
+ client_id: @client_id,
60
+ response_type: "code",
61
+ scope: SCOPE,
62
+ redirect_uri: redirect_uri,
63
+ state: state
64
+ )
65
+ "#{AUTHORIZE_URL}?#{params}"
66
+ end
67
+
68
+ def exchange_code_for_token(code, redirect_uri)
69
+ conn = Faraday.new(url: TOKEN_URL)
70
+ credentials = Base64.strict_encode64("#{@client_id}:#{@client_secret}")
71
+
72
+ response = conn.post { |req| configure_token_request(req, credentials, code, redirect_uri) }
73
+ handle_token_response(response)
74
+ rescue Faraday::Error => e
75
+ raise NetworkError, "Network error during token exchange: #{e.message}"
76
+ end
77
+
78
+ def configure_token_request(req, credentials, code, redirect_uri)
79
+ req.headers["Authorization"] = "Basic #{credentials}"
80
+ req.headers["Content-Type"] = "application/x-www-form-urlencoded"
81
+ req.body = URI.encode_www_form(
82
+ grant_type: "authorization_code",
83
+ code: code,
84
+ redirect_uri: redirect_uri
85
+ )
86
+ end
87
+
88
+ def handle_token_response(response)
89
+ raise Error, "Token exchange failed (HTTP #{response.status}): #{response.body}" unless response.success?
90
+
91
+ data = JSON.parse(response.body)
92
+ token = data["access_token"]
93
+ raise Error, "No access_token in response: #{response.body}" unless token
94
+
95
+ token
96
+ end
97
+ end
98
+ end
99
+ end
@@ -3,7 +3,7 @@
3
3
  module Ticktick
4
4
  module Mcp
5
5
  module Server
6
- VERSION = "0.1.0"
6
+ VERSION = "0.2.0"
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ticktick-mcp-server
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - j-o-lantern0422
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: base64
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: faraday
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -37,11 +51,26 @@ dependencies:
37
51
  - - ">="
38
52
  - !ruby/object:Gem::Version
39
53
  version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: webrick
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
40
68
  description: A Model Context Protocol (MCP) server that provides tools to interact
41
69
  with the TickTick API
42
70
  email:
43
71
  - j.o.lantern0422@gmail.com
44
72
  executables:
73
+ - ticktick-auth
45
74
  - ticktick-mcp-server
46
75
  extensions: []
47
76
  extra_rdoc_files: []
@@ -49,10 +78,15 @@ files:
49
78
  - ".rspec"
50
79
  - ".rubocop.yml"
51
80
  - CHANGELOG.md
81
+ - Dockerfile
82
+ - Dockerfile.dev
52
83
  - LICENSE.txt
53
84
  - README.md
54
85
  - Rakefile
86
+ - exe/ticktick-auth
55
87
  - exe/ticktick-mcp-server
88
+ - lib/ticktick/auth/callback_server.rb
89
+ - lib/ticktick/auth/oauth_flow.rb
56
90
  - lib/ticktick/client.rb
57
91
  - lib/ticktick/errors.rb
58
92
  - lib/ticktick/http_connection.rb