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 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
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ source "https://rubygems.org"
4
+
5
+ gemspec
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
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module TogglTrackMcp
4
+ VERSION = "0.1.0"
5
+ 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: []