toggl_track_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 +5 -0
- data/LICENSE +21 -0
- data/README.md +56 -0
- data/exe/toggl-track-mcp +13 -0
- data/lib/toggl_track_mcp/duration_formatter.rb +38 -0
- data/lib/toggl_track_mcp/toggl_client.rb +150 -0
- data/lib/toggl_track_mcp/tools/create_entry.rb +64 -0
- data/lib/toggl_track_mcp/tools/delete_entry.rb +34 -0
- data/lib/toggl_track_mcp/tools/get_current_entry.rb +51 -0
- data/lib/toggl_track_mcp/tools/get_daily_summary.rb +96 -0
- data/lib/toggl_track_mcp/tools/get_entries_by_date.rb +83 -0
- data/lib/toggl_track_mcp/tools/get_projects.rb +45 -0
- data/lib/toggl_track_mcp/tools/get_today_entries.rb +64 -0
- data/lib/toggl_track_mcp/tools/stop_entry.rb +44 -0
- data/lib/toggl_track_mcp/tools/update_entry.rb +76 -0
- data/lib/toggl_track_mcp/version.rb +5 -0
- data/lib/toggl_track_mcp.rb +28 -0
- metadata +73 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: df6b591be588f2f4143d0fc77150d81c38f065a86c228fc361d2f621c500d6da
|
|
4
|
+
data.tar.gz: 68299d7385388f0d403953a0546246df5042cd2a1e3a1578448d3bebec900d27
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 9c561444f0ae8414c1c7283d7043572a5bcce6387170c31f4e1bfb8352184fe8a78207ac280a2be477b9ead1f4ae6276eff366d0c6d32db4c4f3e6bee6f26cd7
|
|
7
|
+
data.tar.gz: f4bbd2612692459aa6fb9ab379cb08bbc574a1583168711dfa51482fa0977614dcf9a76b7b1adf937b5066cb0f7d51ad1e8537d6e2a173cd342d86b0a2efae8b
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ima1zumi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# toggl-track-mcp
|
|
2
|
+
|
|
3
|
+
An MCP server for Toggl Track.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
gem install toggl_track_mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Or add to your Gemfile:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
gem "toggl_track_mcp"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
## Setup
|
|
18
|
+
|
|
19
|
+
Set your Toggl Track API token as the environment variable `TOGGL_API_TOKEN`. You can find it at [Toggl Track Profile](https://track.toggl.com/profile).
|
|
20
|
+
|
|
21
|
+
`TOGGL_TZ` is the timezone offset (default: `+09:00`).
|
|
22
|
+
|
|
23
|
+
## Usage
|
|
24
|
+
|
|
25
|
+
Example configuration for Claude Desktop:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"toggl-track": {
|
|
31
|
+
"command": "toggl-track-mcp",
|
|
32
|
+
"env": {
|
|
33
|
+
"TOGGL_API_TOKEN": "your_api_token_here"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Tools
|
|
41
|
+
|
|
42
|
+
| Tool | Description |
|
|
43
|
+
|---|---|
|
|
44
|
+
| get_current_entry | Get the currently running time entry |
|
|
45
|
+
| get_today_entries | List today's time entries |
|
|
46
|
+
| get_entries_by_date | Get time entries for a specific date |
|
|
47
|
+
| get_daily_summary | Get a daily summary |
|
|
48
|
+
| get_projects | List projects |
|
|
49
|
+
| create_entry | Create a time entry |
|
|
50
|
+
| update_entry | Update a time entry |
|
|
51
|
+
| stop_entry | Stop the running time entry |
|
|
52
|
+
| delete_entry | Delete a time entry |
|
|
53
|
+
|
|
54
|
+
## License
|
|
55
|
+
|
|
56
|
+
MIT
|
data/exe/toggl-track-mcp
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "toggl_track_mcp"
|
|
5
|
+
|
|
6
|
+
server = MCP::Server.new(
|
|
7
|
+
name: "toggl-track",
|
|
8
|
+
version: TogglTrackMcp::VERSION,
|
|
9
|
+
tools: TogglTrackMcp::TOOLS,
|
|
10
|
+
server_context: { client: TogglTrackMcp::TogglClient.new },
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
MCP::Server::Transports::StdioTransport.new(server).open
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module TogglTrackMcp
|
|
6
|
+
module DurationFormatter
|
|
7
|
+
def format_duration_human(seconds)
|
|
8
|
+
return "0min" if seconds <= 0
|
|
9
|
+
|
|
10
|
+
hours = seconds / 3600
|
|
11
|
+
minutes = (seconds % 3600) / 60
|
|
12
|
+
|
|
13
|
+
if hours > 0
|
|
14
|
+
"#{hours}h#{minutes}min"
|
|
15
|
+
else
|
|
16
|
+
"#{minutes}min"
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def format_time(time_string, tz:)
|
|
21
|
+
Time.parse(time_string).getlocal(tz).strftime("%H:%M")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def parse_date(date_string, tz:)
|
|
25
|
+
case date_string
|
|
26
|
+
when "today", nil
|
|
27
|
+
today = Time.now.getlocal(tz)
|
|
28
|
+
Time.new(today.year, today.month, today.day, 0, 0, 0, tz)
|
|
29
|
+
when "yesterday"
|
|
30
|
+
yesterday = Time.now.getlocal(tz) - 86400
|
|
31
|
+
Time.new(yesterday.year, yesterday.month, yesterday.day, 0, 0, 0, tz)
|
|
32
|
+
else
|
|
33
|
+
parts = date_string.split("-").map(&:to_i)
|
|
34
|
+
Time.new(parts[0], parts[1], parts[2], 0, 0, 0, tz)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module TogglTrackMcp
|
|
9
|
+
class TogglClient
|
|
10
|
+
BASE_URL = "https://api.track.toggl.com/api/v9"
|
|
11
|
+
CREATED_WITH = "toggl-track-mcp"
|
|
12
|
+
|
|
13
|
+
attr_reader :tz
|
|
14
|
+
|
|
15
|
+
def initialize(api_token = ENV.fetch("TOGGL_API_TOKEN"), tz: ENV.fetch("TOGGL_TZ", "+09:00"))
|
|
16
|
+
@api_token = api_token
|
|
17
|
+
@tz = tz
|
|
18
|
+
@workspace_id = nil
|
|
19
|
+
@project_map = nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def workspace_id
|
|
23
|
+
@workspace_id ||= get("/me").fetch("default_workspace_id")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def current_entry
|
|
27
|
+
get("/me/time_entries/current")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def project_map
|
|
31
|
+
@project_map ||= projects.each_with_object({}) { |p, h| h[p["id"]] = p["name"] }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def project_name(project_id)
|
|
35
|
+
return nil unless project_id
|
|
36
|
+
|
|
37
|
+
project_map[project_id]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def today_entries
|
|
41
|
+
today = Time.now.getlocal(@tz)
|
|
42
|
+
start_of_day = Time.new(today.year, today.month, today.day, 0, 0, 0, @tz)
|
|
43
|
+
end_of_day = start_of_day + 86400
|
|
44
|
+
get("/me/time_entries", start_date: start_of_day.iso8601, end_date: end_of_day.iso8601)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def entries_by_date(start_date:, end_date:)
|
|
48
|
+
get("/me/time_entries", start_date: start_date, end_date: end_date)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def create_entry(description:, project_id: nil, tags: nil, start: nil, duration: -1)
|
|
52
|
+
body = {
|
|
53
|
+
description: description,
|
|
54
|
+
workspace_id: workspace_id,
|
|
55
|
+
start: start || Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
56
|
+
duration: duration,
|
|
57
|
+
created_with: CREATED_WITH,
|
|
58
|
+
}
|
|
59
|
+
body[:project_id] = project_id if project_id
|
|
60
|
+
body[:tags] = tags if tags
|
|
61
|
+
|
|
62
|
+
post("/workspaces/#{workspace_id}/time_entries", body)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def update_entry(time_entry_id:, **params)
|
|
66
|
+
body = {}
|
|
67
|
+
body[:description] = params[:description] if params.key?(:description)
|
|
68
|
+
body[:project_id] = params[:project_id] if params.key?(:project_id)
|
|
69
|
+
body[:tags] = params[:tags] if params.key?(:tags)
|
|
70
|
+
body[:start] = params[:start] if params.key?(:start)
|
|
71
|
+
body[:stop] = params[:stop] if params.key?(:stop)
|
|
72
|
+
|
|
73
|
+
put("/workspaces/#{workspace_id}/time_entries/#{time_entry_id}", body)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def delete_entry(time_entry_id:)
|
|
77
|
+
delete("/workspaces/#{workspace_id}/time_entries/#{time_entry_id}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def stop_entry(time_entry_id:)
|
|
81
|
+
patch("/workspaces/#{workspace_id}/time_entries/#{time_entry_id}/stop")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def projects
|
|
85
|
+
get("/workspaces/#{workspace_id}/projects")
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def get(path, params = {})
|
|
91
|
+
uri = build_uri(path, params)
|
|
92
|
+
request = Net::HTTP::Get.new(uri)
|
|
93
|
+
execute(uri, request)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def post(path, body)
|
|
97
|
+
uri = build_uri(path)
|
|
98
|
+
request = Net::HTTP::Post.new(uri)
|
|
99
|
+
request.body = JSON.generate(body)
|
|
100
|
+
request["Content-Type"] = "application/json"
|
|
101
|
+
execute(uri, request)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def put(path, body)
|
|
105
|
+
uri = build_uri(path)
|
|
106
|
+
request = Net::HTTP::Put.new(uri)
|
|
107
|
+
request.body = JSON.generate(body)
|
|
108
|
+
request["Content-Type"] = "application/json"
|
|
109
|
+
execute(uri, request)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def patch(path, body = nil)
|
|
113
|
+
uri = build_uri(path)
|
|
114
|
+
request = Net::HTTP::Patch.new(uri)
|
|
115
|
+
if body
|
|
116
|
+
request.body = JSON.generate(body)
|
|
117
|
+
request["Content-Type"] = "application/json"
|
|
118
|
+
end
|
|
119
|
+
execute(uri, request)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def delete(path)
|
|
123
|
+
uri = build_uri(path)
|
|
124
|
+
request = Net::HTTP::Delete.new(uri)
|
|
125
|
+
execute(uri, request)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_uri(path, params = {})
|
|
129
|
+
uri = URI("#{BASE_URL}#{path}")
|
|
130
|
+
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
131
|
+
uri
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def execute(uri, request)
|
|
135
|
+
request.basic_auth(@api_token, "api_token")
|
|
136
|
+
|
|
137
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
|
|
138
|
+
http.request(request)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
case response
|
|
142
|
+
when Net::HTTPSuccess
|
|
143
|
+
return nil if response.body.nil? || response.body.empty?
|
|
144
|
+
JSON.parse(response.body)
|
|
145
|
+
else
|
|
146
|
+
raise "Toggl API error: #{response.code} #{response.body}"
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module TogglTrackMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class CreateEntry < MCP::Tool
|
|
8
|
+
description "Create a new time entry. By default starts a running timer."
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
description: {
|
|
13
|
+
type: "string",
|
|
14
|
+
description: "What you are working on",
|
|
15
|
+
},
|
|
16
|
+
project_id: {
|
|
17
|
+
type: "integer",
|
|
18
|
+
description: "Project ID to associate with (use get_projects to find IDs)",
|
|
19
|
+
},
|
|
20
|
+
tags: {
|
|
21
|
+
type: "array",
|
|
22
|
+
items: { type: "string" },
|
|
23
|
+
description: "Tags for the entry (auto-created if they don't exist)",
|
|
24
|
+
},
|
|
25
|
+
start: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "Start time in UTC (e.g. 2026-03-08T09:00:00Z). Defaults to now.",
|
|
28
|
+
},
|
|
29
|
+
duration: {
|
|
30
|
+
type: "integer",
|
|
31
|
+
description: "Duration in seconds. Use -1 to start a running timer (default). Use positive value for a completed entry.",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
required: ["description"],
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def call(description:, project_id: nil, tags: nil, start: nil, duration: -1, server_context: nil)
|
|
39
|
+
client = server_context[:client]
|
|
40
|
+
|
|
41
|
+
entry = client.create_entry(
|
|
42
|
+
description: description,
|
|
43
|
+
project_id: project_id,
|
|
44
|
+
tags: tags,
|
|
45
|
+
start: start,
|
|
46
|
+
duration: duration,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
status = duration == -1 ? "Timer started" : "Entry created"
|
|
50
|
+
text = <<~TEXT.gsub(/^\s*\n/, "").chomp
|
|
51
|
+
#{status}:
|
|
52
|
+
Description: #{entry["description"]}
|
|
53
|
+
#{" Project ID: #{entry["project_id"]}" if entry["project_id"]}
|
|
54
|
+
#{" Tags: #{entry["tags"].join(", ")}" if entry["tags"]&.any?}
|
|
55
|
+
Start: #{entry["start"]}
|
|
56
|
+
Entry ID: #{entry["id"]}
|
|
57
|
+
TEXT
|
|
58
|
+
|
|
59
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module TogglTrackMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class DeleteEntry < MCP::Tool
|
|
8
|
+
description "Delete a time entry"
|
|
9
|
+
|
|
10
|
+
annotations(
|
|
11
|
+
destructive_hint: true,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
input_schema(
|
|
15
|
+
properties: {
|
|
16
|
+
time_entry_id: {
|
|
17
|
+
type: "integer",
|
|
18
|
+
description: "The ID of the time entry to delete",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
required: ["time_entry_id"],
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
class << self
|
|
25
|
+
def call(time_entry_id:, server_context: nil)
|
|
26
|
+
client = server_context[:client]
|
|
27
|
+
client.delete_entry(time_entry_id: time_entry_id)
|
|
28
|
+
|
|
29
|
+
MCP::Tool::Response.new([{ type: "text", text: "Time entry #{time_entry_id} deleted." }])
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module TogglTrackMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class GetCurrentEntry < MCP::Tool
|
|
8
|
+
extend DurationFormatter
|
|
9
|
+
|
|
10
|
+
description "Get the currently running time entry"
|
|
11
|
+
|
|
12
|
+
annotations(
|
|
13
|
+
read_only_hint: true,
|
|
14
|
+
destructive_hint: false,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
input_schema(properties: {})
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def call(server_context: nil)
|
|
21
|
+
client = server_context[:client]
|
|
22
|
+
entry = client.current_entry
|
|
23
|
+
|
|
24
|
+
if entry.nil?
|
|
25
|
+
return MCP::Tool::Response.new([{ type: "text", text: "No timer is currently running." }])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
MCP::Tool::Response.new([{ type: "text", text: format_entry(entry, client) }])
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def format_entry(entry, client)
|
|
34
|
+
tz = client.tz
|
|
35
|
+
start_time = Time.parse(entry["start"])
|
|
36
|
+
elapsed = Time.now.to_i - start_time.to_i
|
|
37
|
+
project_name = entry["project_id"] ? client.project_name(entry["project_id"]) : nil
|
|
38
|
+
|
|
39
|
+
<<~TEXT.gsub(/^\s*\n/, "").chomp
|
|
40
|
+
Description: #{entry["description"] || "(no description)"}
|
|
41
|
+
#{"Project: #{project_name}" if project_name}
|
|
42
|
+
#{"Project ID: #{entry["project_id"]}" if entry["project_id"]}
|
|
43
|
+
#{"Tags: #{entry["tags"].join(", ")}" if entry["tags"]&.any?}
|
|
44
|
+
Start: #{start_time.getlocal(tz).strftime("%H:%M")} (#{format_duration_human(elapsed)} elapsed)
|
|
45
|
+
Entry ID: #{entry["id"]}
|
|
46
|
+
TEXT
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module TogglTrackMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class GetDailySummary < MCP::Tool
|
|
8
|
+
extend DurationFormatter
|
|
9
|
+
|
|
10
|
+
description "Get a formatted daily summary of time entries for diary/journal use."
|
|
11
|
+
|
|
12
|
+
annotations(
|
|
13
|
+
read_only_hint: true,
|
|
14
|
+
destructive_hint: false,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
input_schema(
|
|
18
|
+
properties: {
|
|
19
|
+
date: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: 'Target date (YYYY-MM-DD, "today", or "yesterday"). Defaults to today.',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
class << self
|
|
27
|
+
def call(date: nil, server_context: nil)
|
|
28
|
+
client = server_context[:client]
|
|
29
|
+
tz = client.tz
|
|
30
|
+
|
|
31
|
+
start_of_day = parse_date(date, tz: tz)
|
|
32
|
+
end_of_day = (start_of_day + 86400).iso8601
|
|
33
|
+
entries = client.entries_by_date(start_date: start_of_day.iso8601, end_date: end_of_day)
|
|
34
|
+
|
|
35
|
+
if entries.nil? || entries.empty?
|
|
36
|
+
return MCP::Tool::Response.new([{ type: "text", text: "No time entries found." }])
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
sorted = entries.sort_by { |e| e["start"] }
|
|
40
|
+
|
|
41
|
+
timeline = build_timeline(sorted, client)
|
|
42
|
+
project_summary = build_project_summary(sorted, client)
|
|
43
|
+
total = sorted.sum { |e| entry_duration(e) }
|
|
44
|
+
|
|
45
|
+
text = <<~TEXT
|
|
46
|
+
## Timeline
|
|
47
|
+
#{timeline.chomp}
|
|
48
|
+
|
|
49
|
+
## By Project
|
|
50
|
+
#{project_summary.chomp}
|
|
51
|
+
|
|
52
|
+
Total: #{format_duration_human(total)}
|
|
53
|
+
TEXT
|
|
54
|
+
|
|
55
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def build_timeline(entries, client)
|
|
61
|
+
tz = client.tz
|
|
62
|
+
entries.map do |entry|
|
|
63
|
+
start_time = format_time(entry["start"], tz: tz)
|
|
64
|
+
stop_time = entry["stop"] ? format_time(entry["stop"], tz: tz) : "now"
|
|
65
|
+
duration = entry_duration(entry)
|
|
66
|
+
description = entry["description"] || "(no description)"
|
|
67
|
+
project = client.project_name(entry["project_id"])
|
|
68
|
+
label = project || description
|
|
69
|
+
|
|
70
|
+
"#{start_time} - #{stop_time} #{label} (#{format_duration_human(duration)})\n"
|
|
71
|
+
end.join
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def build_project_summary(entries, client)
|
|
75
|
+
by_project = Hash.new(0)
|
|
76
|
+
entries.each do |entry|
|
|
77
|
+
name = client.project_name(entry["project_id"]) || entry["description"] || "(no description)"
|
|
78
|
+
by_project[name] += entry_duration(entry)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
by_project.sort_by { |_, v| -v }.map do |name, seconds|
|
|
82
|
+
"#{name}: #{format_duration_human(seconds)}\n"
|
|
83
|
+
end.join
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def entry_duration(entry)
|
|
87
|
+
if entry["duration"] >= 0
|
|
88
|
+
entry["duration"]
|
|
89
|
+
else
|
|
90
|
+
Time.now.to_i - Time.parse(entry["start"]).to_i
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module TogglTrackMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class GetEntriesByDate < MCP::Tool
|
|
8
|
+
extend DurationFormatter
|
|
9
|
+
|
|
10
|
+
description "Get time entries by date or date range"
|
|
11
|
+
|
|
12
|
+
annotations(
|
|
13
|
+
read_only_hint: true,
|
|
14
|
+
destructive_hint: false,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
input_schema(
|
|
18
|
+
properties: {
|
|
19
|
+
date: {
|
|
20
|
+
type: "string",
|
|
21
|
+
description: 'YYYY-MM-DD, "today", or "yesterday"',
|
|
22
|
+
},
|
|
23
|
+
end_date: {
|
|
24
|
+
type: "string",
|
|
25
|
+
description: "Optional end date for range query (YYYY-MM-DD)",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
required: ["date"],
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def call(date:, end_date: nil, server_context: nil)
|
|
33
|
+
client = server_context[:client]
|
|
34
|
+
tz = client.tz
|
|
35
|
+
|
|
36
|
+
start_of_day = parse_date(date, tz: tz)
|
|
37
|
+
end_of_range = end_date ? parse_date(end_date, tz: tz) + 86400 : start_of_day + 86400
|
|
38
|
+
|
|
39
|
+
entries = client.entries_by_date(
|
|
40
|
+
start_date: start_of_day.iso8601,
|
|
41
|
+
end_date: end_of_range.iso8601,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if entries.nil? || entries.empty?
|
|
45
|
+
return MCP::Tool::Response.new([{ type: "text", text: "No time entries found." }])
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
total_seconds = 0
|
|
49
|
+
lines = entries.map do |entry|
|
|
50
|
+
duration = entry["duration"]
|
|
51
|
+
if duration >= 0
|
|
52
|
+
total_seconds += duration
|
|
53
|
+
else
|
|
54
|
+
duration = Time.now.to_i - Time.parse(entry["start"]).to_i
|
|
55
|
+
total_seconds += duration
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
project_name = entry["project_id"] ? client.project_name(entry["project_id"]) : nil
|
|
59
|
+
<<~TEXT.gsub(/^\s*\n/, "").chomp
|
|
60
|
+
Description: #{entry["description"] || "(no description)"}
|
|
61
|
+
#{" Project: #{project_name}" if project_name}
|
|
62
|
+
#{" Project ID: #{entry["project_id"]}" if entry["project_id"]}
|
|
63
|
+
#{" Tags: #{entry["tags"].join(", ")}" if entry["tags"]&.any?}
|
|
64
|
+
Start: #{format_time(entry["start"], tz: tz)}
|
|
65
|
+
#{" Stop: #{format_time(entry["stop"], tz: tz)}" if entry["stop"]}
|
|
66
|
+
Duration: #{format_duration_human(duration)}
|
|
67
|
+
#{" Running" if entry["duration"] < 0}
|
|
68
|
+
Entry ID: #{entry["id"]}
|
|
69
|
+
TEXT
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
text = <<~TEXT.chomp
|
|
73
|
+
Entries (#{entries.size} total, #{format_duration_human(total_seconds)}):
|
|
74
|
+
|
|
75
|
+
#{lines.join("\n\n")}
|
|
76
|
+
TEXT
|
|
77
|
+
|
|
78
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module TogglTrackMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class GetProjects < MCP::Tool
|
|
8
|
+
description "Get all projects in the workspace"
|
|
9
|
+
|
|
10
|
+
annotations(
|
|
11
|
+
read_only_hint: true,
|
|
12
|
+
destructive_hint: false,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
input_schema(properties: {})
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def call(server_context: nil)
|
|
19
|
+
client = server_context[:client]
|
|
20
|
+
projects = client.projects
|
|
21
|
+
|
|
22
|
+
if projects.nil? || projects.empty?
|
|
23
|
+
return MCP::Tool::Response.new([{ type: "text", text: "No projects found." }])
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
lines = projects.select { |p| p["active"] }.map do |project|
|
|
27
|
+
<<~TEXT.gsub(/^\s*\n/, "").chomp
|
|
28
|
+
Name: #{project["name"]}
|
|
29
|
+
ID: #{project["id"]}
|
|
30
|
+
#{" Color: #{project["color"]}" if project["color"]}
|
|
31
|
+
TEXT
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
text = <<~TEXT.chomp
|
|
35
|
+
Projects (#{lines.size}):
|
|
36
|
+
|
|
37
|
+
#{lines.join("\n\n")}
|
|
38
|
+
TEXT
|
|
39
|
+
|
|
40
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module TogglTrackMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class GetTodayEntries < MCP::Tool
|
|
8
|
+
extend DurationFormatter
|
|
9
|
+
|
|
10
|
+
description "Get all time entries for today"
|
|
11
|
+
|
|
12
|
+
annotations(
|
|
13
|
+
read_only_hint: true,
|
|
14
|
+
destructive_hint: false,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
input_schema(properties: {})
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def call(server_context: nil)
|
|
21
|
+
client = server_context[:client]
|
|
22
|
+
entries = client.today_entries
|
|
23
|
+
|
|
24
|
+
if entries.nil? || entries.empty?
|
|
25
|
+
return MCP::Tool::Response.new([{ type: "text", text: "No time entries for today." }])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
tz = client.tz
|
|
29
|
+
total_seconds = 0
|
|
30
|
+
lines = entries.map do |entry|
|
|
31
|
+
duration = entry["duration"]
|
|
32
|
+
if duration >= 0
|
|
33
|
+
total_seconds += duration
|
|
34
|
+
else
|
|
35
|
+
duration = Time.now.to_i - Time.parse(entry["start"]).to_i
|
|
36
|
+
total_seconds += duration
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
project_name = entry["project_id"] ? client.project_name(entry["project_id"]) : nil
|
|
40
|
+
<<~TEXT.gsub(/^\s*\n/, "").chomp
|
|
41
|
+
Description: #{entry["description"] || "(no description)"}
|
|
42
|
+
#{" Project: #{project_name}" if project_name}
|
|
43
|
+
#{" Project ID: #{entry["project_id"]}" if entry["project_id"]}
|
|
44
|
+
#{" Tags: #{entry["tags"].join(", ")}" if entry["tags"]&.any?}
|
|
45
|
+
Start: #{format_time(entry["start"], tz: tz)}
|
|
46
|
+
#{" Stop: #{format_time(entry["stop"], tz: tz)}" if entry["stop"]}
|
|
47
|
+
Duration: #{format_duration_human(duration)}
|
|
48
|
+
#{" Running" if entry["duration"] < 0}
|
|
49
|
+
Entry ID: #{entry["id"]}
|
|
50
|
+
TEXT
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
text = <<~TEXT.chomp
|
|
54
|
+
Today's entries (#{entries.size} total, #{format_duration_human(total_seconds)}):
|
|
55
|
+
|
|
56
|
+
#{lines.join("\n\n")}
|
|
57
|
+
TEXT
|
|
58
|
+
|
|
59
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module TogglTrackMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class StopEntry < MCP::Tool
|
|
8
|
+
extend DurationFormatter
|
|
9
|
+
|
|
10
|
+
description "Stop the currently running timer"
|
|
11
|
+
|
|
12
|
+
input_schema(properties: {})
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def call(server_context: nil)
|
|
16
|
+
client = server_context[:client]
|
|
17
|
+
current = client.current_entry
|
|
18
|
+
|
|
19
|
+
if current.nil?
|
|
20
|
+
return MCP::Tool::Response.new([{ type: "text", text: "No timer is currently running." }])
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
entry = client.stop_entry(time_entry_id: current["id"])
|
|
24
|
+
|
|
25
|
+
tz = client.tz
|
|
26
|
+
elapsed = Time.now.to_i - Time.parse(entry["start"]).to_i
|
|
27
|
+
project_name = entry["project_id"] ? client.project_name(entry["project_id"]) : nil
|
|
28
|
+
text = <<~TEXT.gsub(/^\s*\n/, "").chomp
|
|
29
|
+
Timer stopped:
|
|
30
|
+
Description: #{entry["description"] || "(no description)"}
|
|
31
|
+
#{" Project: #{project_name}" if project_name}
|
|
32
|
+
#{" Project ID: #{entry["project_id"]}" if entry["project_id"]}
|
|
33
|
+
Start: #{format_time(entry["start"], tz: tz)}
|
|
34
|
+
#{" Stop: #{format_time(entry["stop"], tz: tz)}" if entry["stop"]}
|
|
35
|
+
Duration: #{format_duration_human(elapsed)}
|
|
36
|
+
Entry ID: #{entry["id"]}
|
|
37
|
+
TEXT
|
|
38
|
+
|
|
39
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
4
|
+
|
|
5
|
+
module TogglTrackMcp
|
|
6
|
+
module Tools
|
|
7
|
+
class UpdateEntry < MCP::Tool
|
|
8
|
+
description "Update an existing time entry"
|
|
9
|
+
|
|
10
|
+
input_schema(
|
|
11
|
+
properties: {
|
|
12
|
+
time_entry_id: {
|
|
13
|
+
type: "integer",
|
|
14
|
+
description: "The ID of the time entry to update",
|
|
15
|
+
},
|
|
16
|
+
description: {
|
|
17
|
+
type: "string",
|
|
18
|
+
description: "New description",
|
|
19
|
+
},
|
|
20
|
+
project_id: {
|
|
21
|
+
type: "integer",
|
|
22
|
+
description: "New project ID",
|
|
23
|
+
},
|
|
24
|
+
tags: {
|
|
25
|
+
type: "array",
|
|
26
|
+
items: { type: "string" },
|
|
27
|
+
description: "New tags (replaces existing tags)",
|
|
28
|
+
},
|
|
29
|
+
start: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: "New start time in ISO 8601 format (e.g. 2026-03-08T09:00:00Z)",
|
|
32
|
+
},
|
|
33
|
+
stop: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "New stop time in ISO 8601 format (e.g. 2026-03-08T10:30:00Z)",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
required: ["time_entry_id"],
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
class << self
|
|
42
|
+
def call(time_entry_id:, description: nil, project_id: nil, tags: nil, start: nil, stop: nil, server_context: nil)
|
|
43
|
+
client = server_context[:client]
|
|
44
|
+
|
|
45
|
+
params = {}
|
|
46
|
+
params[:description] = description unless description.nil?
|
|
47
|
+
params[:project_id] = project_id unless project_id.nil?
|
|
48
|
+
params[:tags] = tags unless tags.nil?
|
|
49
|
+
params[:start] = start unless start.nil?
|
|
50
|
+
params[:stop] = stop unless stop.nil?
|
|
51
|
+
|
|
52
|
+
if params.empty?
|
|
53
|
+
return MCP::Tool::Response.new(
|
|
54
|
+
[{ type: "text", text: "No fields to update. Provide at least one of: description, project_id, tags, start, stop." }],
|
|
55
|
+
error: true,
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
entry = client.update_entry(time_entry_id: time_entry_id, **params)
|
|
60
|
+
|
|
61
|
+
text = <<~TEXT.gsub(/^\s*\n/, "").chomp
|
|
62
|
+
Entry updated:
|
|
63
|
+
Description: #{entry["description"] || "(no description)"}
|
|
64
|
+
#{" Project ID: #{entry["project_id"]}" if entry["project_id"]}
|
|
65
|
+
#{" Tags: #{entry["tags"].join(", ")}" if entry["tags"]&.any?}
|
|
66
|
+
#{" Start: #{entry["start"]}" if entry["start"]}
|
|
67
|
+
#{" Stop: #{entry["stop"]}" if entry["stop"]}
|
|
68
|
+
Entry ID: #{entry["id"]}
|
|
69
|
+
TEXT
|
|
70
|
+
|
|
71
|
+
MCP::Tool::Response.new([{ type: "text", text: text }])
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "toggl_track_mcp/version"
|
|
4
|
+
require_relative "toggl_track_mcp/duration_formatter"
|
|
5
|
+
require_relative "toggl_track_mcp/toggl_client"
|
|
6
|
+
require_relative "toggl_track_mcp/tools/get_current_entry"
|
|
7
|
+
require_relative "toggl_track_mcp/tools/get_today_entries"
|
|
8
|
+
require_relative "toggl_track_mcp/tools/get_entries_by_date"
|
|
9
|
+
require_relative "toggl_track_mcp/tools/get_daily_summary"
|
|
10
|
+
require_relative "toggl_track_mcp/tools/get_projects"
|
|
11
|
+
require_relative "toggl_track_mcp/tools/create_entry"
|
|
12
|
+
require_relative "toggl_track_mcp/tools/update_entry"
|
|
13
|
+
require_relative "toggl_track_mcp/tools/stop_entry"
|
|
14
|
+
require_relative "toggl_track_mcp/tools/delete_entry"
|
|
15
|
+
|
|
16
|
+
module TogglTrackMcp
|
|
17
|
+
TOOLS = [
|
|
18
|
+
Tools::GetCurrentEntry,
|
|
19
|
+
Tools::GetTodayEntries,
|
|
20
|
+
Tools::GetEntriesByDate,
|
|
21
|
+
Tools::GetDailySummary,
|
|
22
|
+
Tools::GetProjects,
|
|
23
|
+
Tools::CreateEntry,
|
|
24
|
+
Tools::UpdateEntry,
|
|
25
|
+
Tools::StopEntry,
|
|
26
|
+
Tools::DeleteEntry,
|
|
27
|
+
].freeze
|
|
28
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: toggl_track_mcp
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- ima1zumi
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-04-01 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: mcp
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.8.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.8.0
|
|
26
|
+
description: A Model Context Protocol (MCP) server that provides tools to interact
|
|
27
|
+
with Toggl Track time tracking API.
|
|
28
|
+
executables:
|
|
29
|
+
- toggl-track-mcp
|
|
30
|
+
extensions: []
|
|
31
|
+
extra_rdoc_files: []
|
|
32
|
+
files:
|
|
33
|
+
- Gemfile
|
|
34
|
+
- LICENSE
|
|
35
|
+
- README.md
|
|
36
|
+
- exe/toggl-track-mcp
|
|
37
|
+
- lib/toggl_track_mcp.rb
|
|
38
|
+
- lib/toggl_track_mcp/duration_formatter.rb
|
|
39
|
+
- lib/toggl_track_mcp/toggl_client.rb
|
|
40
|
+
- lib/toggl_track_mcp/tools/create_entry.rb
|
|
41
|
+
- lib/toggl_track_mcp/tools/delete_entry.rb
|
|
42
|
+
- lib/toggl_track_mcp/tools/get_current_entry.rb
|
|
43
|
+
- lib/toggl_track_mcp/tools/get_daily_summary.rb
|
|
44
|
+
- lib/toggl_track_mcp/tools/get_entries_by_date.rb
|
|
45
|
+
- lib/toggl_track_mcp/tools/get_projects.rb
|
|
46
|
+
- lib/toggl_track_mcp/tools/get_today_entries.rb
|
|
47
|
+
- lib/toggl_track_mcp/tools/stop_entry.rb
|
|
48
|
+
- lib/toggl_track_mcp/tools/update_entry.rb
|
|
49
|
+
- lib/toggl_track_mcp/version.rb
|
|
50
|
+
homepage: https://github.com/ima1zumi/toggl-track-mcp
|
|
51
|
+
licenses:
|
|
52
|
+
- MIT
|
|
53
|
+
metadata:
|
|
54
|
+
homepage_uri: https://github.com/ima1zumi/toggl-track-mcp
|
|
55
|
+
source_code_uri: https://github.com/ima1zumi/toggl-track-mcp
|
|
56
|
+
rdoc_options: []
|
|
57
|
+
require_paths:
|
|
58
|
+
- lib
|
|
59
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
60
|
+
requirements:
|
|
61
|
+
- - ">="
|
|
62
|
+
- !ruby/object:Gem::Version
|
|
63
|
+
version: 3.1.0
|
|
64
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - ">="
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: '0'
|
|
69
|
+
requirements: []
|
|
70
|
+
rubygems_version: 3.6.2
|
|
71
|
+
specification_version: 4
|
|
72
|
+
summary: An MCP server for Toggl Track
|
|
73
|
+
test_files: []
|