mcpeasy 0.1.0 → 0.3.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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/.claudeignore +0 -3
  3. data/.mcp.json +19 -1
  4. data/CHANGELOG.md +59 -0
  5. data/CLAUDE.md +19 -5
  6. data/README.md +19 -3
  7. data/lib/mcpeasy/cli.rb +62 -10
  8. data/lib/mcpeasy/config.rb +22 -1
  9. data/lib/mcpeasy/setup.rb +1 -0
  10. data/lib/mcpeasy/version.rb +1 -1
  11. data/lib/utilities/gcal/README.md +11 -3
  12. data/lib/utilities/gcal/cli.rb +110 -108
  13. data/lib/utilities/gcal/mcp.rb +463 -308
  14. data/lib/utilities/gcal/service.rb +312 -0
  15. data/lib/utilities/gdrive/README.md +3 -3
  16. data/lib/utilities/gdrive/cli.rb +98 -96
  17. data/lib/utilities/gdrive/mcp.rb +290 -288
  18. data/lib/utilities/gdrive/service.rb +293 -0
  19. data/lib/utilities/gmail/README.md +278 -0
  20. data/lib/utilities/gmail/cli.rb +264 -0
  21. data/lib/utilities/gmail/mcp.rb +846 -0
  22. data/lib/utilities/gmail/service.rb +547 -0
  23. data/lib/utilities/gmeet/cli.rb +131 -129
  24. data/lib/utilities/gmeet/mcp.rb +374 -372
  25. data/lib/utilities/gmeet/service.rb +411 -0
  26. data/lib/utilities/notion/README.md +287 -0
  27. data/lib/utilities/notion/cli.rb +245 -0
  28. data/lib/utilities/notion/mcp.rb +607 -0
  29. data/lib/utilities/notion/service.rb +327 -0
  30. data/lib/utilities/slack/README.md +3 -3
  31. data/lib/utilities/slack/cli.rb +69 -54
  32. data/lib/utilities/slack/mcp.rb +277 -226
  33. data/lib/utilities/slack/service.rb +134 -0
  34. data/mcpeasy.gemspec +6 -1
  35. metadata +87 -10
  36. data/env.template +0 -11
  37. data/lib/utilities/gcal/gcal_tool.rb +0 -308
  38. data/lib/utilities/gdrive/gdrive_tool.rb +0 -291
  39. data/lib/utilities/gmeet/gmeet_tool.rb +0 -407
  40. data/lib/utilities/slack/slack_tool.rb +0 -119
  41. data/logs/.keep +0 -0
@@ -0,0 +1,312 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "google/apis/calendar_v3"
5
+ require "googleauth"
6
+ require "signet/oauth_2/client"
7
+ require "fileutils"
8
+ require "json"
9
+ require "time"
10
+ require_relative "../_google/auth_server"
11
+ require_relative "../../mcpeasy/config"
12
+
13
+ module Gcal
14
+ class Service
15
+ SCOPES = [
16
+ "https://www.googleapis.com/auth/calendar.readonly",
17
+ "https://www.googleapis.com/auth/drive.readonly"
18
+ ]
19
+ SCOPE = SCOPES.join(" ")
20
+
21
+ def initialize(skip_auth: false)
22
+ ensure_env!
23
+ @service = Google::Apis::CalendarV3::CalendarService.new
24
+ @service.authorization = authorize unless skip_auth
25
+ end
26
+
27
+ def list_events(start_date: nil, end_date: nil, max_results: 20, calendar_id: "primary")
28
+ # Default to today if no start date provided
29
+ now = Time.now
30
+ start_time = start_date ? Time.parse("#{start_date} 00:00:00") : Time.new(now.year, now.month, now.day, 0, 0, 0)
31
+ # Default to 7 days from start if no end date provided
32
+ end_time = end_date ? Time.parse("#{end_date} 23:59:59") : start_time + 7 * 24 * 60 * 60
33
+
34
+ results = @service.list_events(
35
+ calendar_id,
36
+ max_results: max_results,
37
+ single_events: true,
38
+ order_by: "startTime",
39
+ time_min: start_time.utc.iso8601,
40
+ time_max: end_time.utc.iso8601
41
+ )
42
+
43
+ events = results.items.map do |event|
44
+ {
45
+ id: event.id,
46
+ summary: event.summary,
47
+ description: event.description,
48
+ location: event.location,
49
+ start: format_event_time(event.start),
50
+ end: format_event_time(event.end),
51
+ attendees: format_attendees(event.attendees),
52
+ html_link: event.html_link,
53
+ calendar_id: calendar_id
54
+ }
55
+ end
56
+
57
+ {events: events, count: events.length}
58
+ rescue Google::Apis::Error => e
59
+ raise "Google Calendar API Error: #{e.message}"
60
+ rescue => e
61
+ log_error("list_events", e)
62
+ raise e
63
+ end
64
+
65
+ def list_calendars
66
+ results = @service.list_calendar_lists
67
+
68
+ calendars = results.items.map do |calendar|
69
+ {
70
+ id: calendar.id,
71
+ summary: calendar.summary,
72
+ description: calendar.description,
73
+ time_zone: calendar.time_zone,
74
+ access_role: calendar.access_role,
75
+ primary: calendar.primary
76
+ }
77
+ end
78
+
79
+ {calendars: calendars, count: calendars.length}
80
+ rescue Google::Apis::Error => e
81
+ raise "Google Calendar API Error: #{e.message}"
82
+ rescue => e
83
+ log_error("list_calendars", e)
84
+ raise e
85
+ end
86
+
87
+ def search_events(query, start_date: nil, end_date: nil, max_results: 10)
88
+ # Default to today if no start date provided
89
+ now = Time.now
90
+ start_time = start_date ? Time.parse("#{start_date} 00:00:00") : Time.new(now.year, now.month, now.day, 0, 0, 0)
91
+ # Default to 30 days from start if no end date provided
92
+ end_time = end_date ? Time.parse("#{end_date} 23:59:59") : start_time + 30 * 24 * 60 * 60
93
+
94
+ results = @service.list_events(
95
+ "primary",
96
+ q: query,
97
+ max_results: max_results,
98
+ single_events: true,
99
+ order_by: "startTime",
100
+ time_min: start_time.utc.iso8601,
101
+ time_max: end_time.utc.iso8601
102
+ )
103
+
104
+ events = results.items.map do |event|
105
+ {
106
+ id: event.id,
107
+ summary: event.summary,
108
+ description: event.description,
109
+ location: event.location,
110
+ start: format_event_time(event.start),
111
+ end: format_event_time(event.end),
112
+ attendees: format_attendees(event.attendees),
113
+ html_link: event.html_link,
114
+ calendar_id: "primary"
115
+ }
116
+ end
117
+
118
+ {events: events, count: events.length}
119
+ rescue Google::Apis::Error => e
120
+ raise "Google Calendar API Error: #{e.message}"
121
+ rescue => e
122
+ log_error("search_events", e)
123
+ raise e
124
+ end
125
+
126
+ def test_connection
127
+ calendar = @service.get_calendar("primary")
128
+ {
129
+ ok: true,
130
+ user: calendar.summary,
131
+ email: calendar.id
132
+ }
133
+ rescue Google::Apis::Error => e
134
+ raise "Google Calendar API Error: #{e.message}"
135
+ rescue => e
136
+ log_error("test_connection", e)
137
+ raise e
138
+ end
139
+
140
+ def authenticate
141
+ perform_auth_flow
142
+ {success: true}
143
+ rescue => e
144
+ {success: false, error: e.message}
145
+ end
146
+
147
+ def perform_auth_flow
148
+ client_id = Mcpeasy::Config.google_client_id
149
+ client_secret = Mcpeasy::Config.google_client_secret
150
+
151
+ unless client_id && client_secret
152
+ raise "Google credentials not found. Please save your credentials.json file using: mcpz config set_google_credentials <path_to_credentials.json>"
153
+ end
154
+
155
+ # Create credentials using OAuth2 flow with localhost redirect
156
+ redirect_uri = "http://localhost:8080"
157
+ client = Signet::OAuth2::Client.new(
158
+ client_id: client_id,
159
+ client_secret: client_secret,
160
+ scope: SCOPE,
161
+ redirect_uri: redirect_uri,
162
+ authorization_uri: "https://accounts.google.com/o/oauth2/auth",
163
+ token_credential_uri: "https://oauth2.googleapis.com/token"
164
+ )
165
+
166
+ # Generate authorization URL
167
+ url = client.authorization_uri.to_s
168
+
169
+ puts "DEBUG: Client ID: #{client_id[0..20]}..."
170
+ puts "DEBUG: Scope: #{SCOPE}"
171
+ puts "DEBUG: Redirect URI: #{redirect_uri}"
172
+ puts
173
+
174
+ # Start callback server to capture OAuth code
175
+ puts "Starting temporary web server to capture OAuth callback..."
176
+ puts "Opening authorization URL in your default browser..."
177
+ puts url
178
+ puts
179
+
180
+ # Automatically open URL in default browser on macOS/Unix
181
+ if system("which open > /dev/null 2>&1")
182
+ system("open", url)
183
+ else
184
+ puts "Could not automatically open browser. Please copy the URL above manually."
185
+ end
186
+ puts
187
+ puts "Waiting for OAuth callback... (will timeout in 60 seconds)"
188
+
189
+ # Wait for the authorization code with timeout
190
+ code = GoogleAuthServer.capture_auth_code
191
+
192
+ unless code
193
+ raise "Failed to receive authorization code. Please try again."
194
+ end
195
+
196
+ puts "✅ Authorization code received!"
197
+ client.code = code
198
+ client.fetch_access_token!
199
+
200
+ # Save credentials to config
201
+ credentials_data = {
202
+ client_id: client.client_id,
203
+ client_secret: client.client_secret,
204
+ scope: client.scope,
205
+ refresh_token: client.refresh_token,
206
+ access_token: client.access_token,
207
+ expires_at: client.expires_at
208
+ }
209
+
210
+ Mcpeasy::Config.save_google_token(credentials_data)
211
+ puts "✅ Authentication successful! Token saved to config"
212
+
213
+ client
214
+ rescue => e
215
+ log_error("perform_auth_flow", e)
216
+ raise "Authentication flow failed: #{e.message}"
217
+ end
218
+
219
+ private
220
+
221
+ def authorize
222
+ credentials_data = Mcpeasy::Config.google_token
223
+ unless credentials_data
224
+ raise <<~ERROR
225
+ Google Calendar authentication required!
226
+ Run the auth command first:
227
+ mcpz gcal auth
228
+ ERROR
229
+ end
230
+
231
+ client = Signet::OAuth2::Client.new(
232
+ client_id: credentials_data.client_id,
233
+ client_secret: credentials_data.client_secret,
234
+ scope: credentials_data.scope.respond_to?(:to_a) ? credentials_data.scope.to_a.join(" ") : credentials_data.scope.to_s,
235
+ refresh_token: credentials_data.refresh_token,
236
+ access_token: credentials_data.access_token,
237
+ token_credential_uri: "https://oauth2.googleapis.com/token"
238
+ )
239
+
240
+ # Check if token needs refresh
241
+ if credentials_data.expires_at
242
+ expires_at = if credentials_data.expires_at.is_a?(String)
243
+ Time.parse(credentials_data.expires_at)
244
+ else
245
+ Time.at(credentials_data.expires_at)
246
+ end
247
+
248
+ if Time.now >= expires_at
249
+ client.refresh!
250
+ # Update saved credentials with new access token
251
+ updated_data = {
252
+ client_id: credentials_data.client_id,
253
+ client_secret: credentials_data.client_secret,
254
+ scope: credentials_data.scope.respond_to?(:to_a) ? credentials_data.scope.to_a.join(" ") : credentials_data.scope.to_s,
255
+ refresh_token: credentials_data.refresh_token,
256
+ access_token: client.access_token,
257
+ expires_at: client.expires_at
258
+ }
259
+ Mcpeasy::Config.save_google_token(updated_data)
260
+ end
261
+ end
262
+
263
+ client
264
+ rescue JSON::ParserError
265
+ raise "Invalid token data. Please re-run: mcpz gcal auth"
266
+ rescue => e
267
+ log_error("authorize", e)
268
+ raise "Authentication failed: #{e.message}"
269
+ end
270
+
271
+ def ensure_env!
272
+ unless Mcpeasy::Config.google_client_id && Mcpeasy::Config.google_client_secret
273
+ raise <<~ERROR
274
+ Google API credentials not configured!
275
+ Please save your Google credentials.json file using:
276
+ mcpz config set_google_credentials <path_to_credentials.json>
277
+ ERROR
278
+ end
279
+ end
280
+
281
+ def format_event_time(event_time)
282
+ return nil unless event_time
283
+
284
+ if event_time.date
285
+ # All-day event
286
+ {date: event_time.date}
287
+ elsif event_time.date_time
288
+ # Specific time event
289
+ {date_time: event_time.date_time.iso8601}
290
+ else
291
+ nil
292
+ end
293
+ end
294
+
295
+ def format_attendees(attendees)
296
+ return [] unless attendees
297
+
298
+ attendees.map do |attendee|
299
+ attendee.email
300
+ end.compact
301
+ end
302
+
303
+ def log_error(method, error)
304
+ Mcpeasy::Config.ensure_config_dirs
305
+ File.write(
306
+ Mcpeasy::Config.log_file_path("gcal", "error"),
307
+ "#{Time.now}: #{method} error: #{error.class}: #{error.message}\n#{error.backtrace.join("\n")}\n",
308
+ mode: "a"
309
+ )
310
+ end
311
+ end
312
+ end
@@ -233,14 +233,14 @@ tail -f logs/mcp_gdrive_startup.log
233
233
  utilities/gdrive/
234
234
  ├── cli.rb # Thor-based CLI interface
235
235
  ├── mcp.rb # MCP server implementation
236
- ├── gdrive_tool.rb # Google Drive API wrapper
236
+ ├── service.rb # Google Drive API wrapper
237
237
  └── README.md # This file
238
238
  ```
239
239
 
240
240
  ### Adding New Features
241
241
 
242
- 1. **New API methods**: Add to `GdriveTool` class
243
- 2. **New CLI commands**: Add to `GdriveCLI` class
242
+ 1. **New API methods**: Add to `Service` class
243
+ 2. **New CLI commands**: Add to `CLI` class
244
244
  3. **New MCP tools**: Add to `MCPServer` class
245
245
 
246
246
  ### Testing
@@ -2,117 +2,119 @@
2
2
 
3
3
  require "bundler/setup"
4
4
  require "thor"
5
- require_relative "gdrive_tool"
6
-
7
- class GdriveCLI < Thor
8
- desc "test", "Test the Google Drive API connection"
9
- def test
10
- response = tool.test_connection
11
-
12
- if response[:ok]
13
- puts "✅ Successfully connected to Google Drive"
14
- puts " User: #{response[:user]} (#{response[:email]})"
15
- if response[:storage_used] && response[:storage_limit]
16
- puts " Storage: #{format_bytes(response[:storage_used])} / #{format_bytes(response[:storage_limit])}"
5
+ require_relative "service"
6
+
7
+ module Gdrive
8
+ class CLI < Thor
9
+ desc "test", "Test the Google Drive API connection"
10
+ def test
11
+ response = tool.test_connection
12
+
13
+ if response[:ok]
14
+ puts " Successfully connected to Google Drive"
15
+ puts " User: #{response[:user]} (#{response[:email]})"
16
+ if response[:storage_used] && response[:storage_limit]
17
+ puts " Storage: #{format_bytes(response[:storage_used])} / #{format_bytes(response[:storage_limit])}"
18
+ end
19
+ else
20
+ warn "❌ Connection test failed"
17
21
  end
18
- else
19
- warn "❌ Connection test failed"
22
+ rescue RuntimeError => e
23
+ puts "❌ Failed to connect to Google Drive: #{e.message}"
24
+ exit 1
20
25
  end
21
- rescue RuntimeError => e
22
- puts "❌ Failed to connect to Google Drive: #{e.message}"
23
- exit 1
24
- end
25
26
 
26
- desc "search QUERY", "Search for files in Google Drive"
27
- method_option :max_results, type: :numeric, default: 10, aliases: "-n"
28
- def search(query)
29
- result = tool.search_files(query, max_results: options[:max_results])
30
- files = result[:files]
31
-
32
- if files.empty?
33
- puts "🔍 No files found matching '#{query}'"
34
- else
35
- puts "🔍 Found #{result[:count]} file(s) matching '#{query}':"
36
- files.each_with_index do |file, index|
37
- puts " #{index + 1}. #{file[:name]}"
38
- puts " ID: #{file[:id]}"
39
- puts " Type: #{file[:mime_type]}"
40
- puts " Size: #{format_bytes(file[:size])}"
41
- puts " Modified: #{file[:modified_time]}"
42
- puts " Link: #{file[:web_view_link]}"
43
- puts
27
+ desc "search QUERY", "Search for files in Google Drive"
28
+ method_option :max_results, type: :numeric, default: 10, aliases: "-n"
29
+ def search(query)
30
+ result = tool.search_files(query, max_results: options[:max_results])
31
+ files = result[:files]
32
+
33
+ if files.empty?
34
+ puts "🔍 No files found matching '#{query}'"
35
+ else
36
+ puts "🔍 Found #{result[:count]} file(s) matching '#{query}':"
37
+ files.each_with_index do |file, index|
38
+ puts " #{index + 1}. #{file[:name]}"
39
+ puts " ID: #{file[:id]}"
40
+ puts " Type: #{file[:mime_type]}"
41
+ puts " Size: #{format_bytes(file[:size])}"
42
+ puts " Modified: #{file[:modified_time]}"
43
+ puts " Link: #{file[:web_view_link]}"
44
+ puts
45
+ end
44
46
  end
47
+ rescue RuntimeError => e
48
+ warn "❌ Failed to search files: #{e.message}"
49
+ exit 1
45
50
  end
46
- rescue RuntimeError => e
47
- warn "❌ Failed to search files: #{e.message}"
48
- exit 1
49
- end
50
51
 
51
- desc "list", "List recent files in Google Drive"
52
- method_option :max_results, type: :numeric, default: 20, aliases: "-n"
53
- def list
54
- result = tool.list_files(max_results: options[:max_results])
55
- files = result[:files]
56
-
57
- if files.empty?
58
- puts "📂 No files found in Google Drive"
59
- else
60
- puts "📂 Recent #{result[:count]} file(s):"
61
- files.each_with_index do |file, index|
62
- puts " #{index + 1}. #{file[:name]}"
63
- puts " ID: #{file[:id]}"
64
- puts " Type: #{file[:mime_type]}"
65
- puts " Size: #{format_bytes(file[:size])}"
66
- puts " Modified: #{file[:modified_time]}"
67
- puts " Link: #{file[:web_view_link]}"
68
- puts
52
+ desc "list", "List recent files in Google Drive"
53
+ method_option :max_results, type: :numeric, default: 20, aliases: "-n"
54
+ def list
55
+ result = tool.list_files(max_results: options[:max_results])
56
+ files = result[:files]
57
+
58
+ if files.empty?
59
+ puts "📂 No files found in Google Drive"
60
+ else
61
+ puts "📂 Recent #{result[:count]} file(s):"
62
+ files.each_with_index do |file, index|
63
+ puts " #{index + 1}. #{file[:name]}"
64
+ puts " ID: #{file[:id]}"
65
+ puts " Type: #{file[:mime_type]}"
66
+ puts " Size: #{format_bytes(file[:size])}"
67
+ puts " Modified: #{file[:modified_time]}"
68
+ puts " Link: #{file[:web_view_link]}"
69
+ puts
70
+ end
69
71
  end
72
+ rescue RuntimeError => e
73
+ warn "❌ Failed to list files: #{e.message}"
74
+ exit 1
70
75
  end
71
- rescue RuntimeError => e
72
- warn "❌ Failed to list files: #{e.message}"
73
- exit 1
74
- end
75
76
 
76
- desc "get FILE_ID", "Get content of a specific file"
77
- method_option :output, type: :string, aliases: "-o", desc: "Output file path"
78
- def get(file_id)
79
- result = tool.get_file_content(file_id)
80
-
81
- puts "📄 #{result[:name]}"
82
- puts " Type: #{result[:mime_type]}"
83
- puts " Size: #{format_bytes(result[:size])}"
84
- puts
85
-
86
- if options[:output]
87
- File.write(options[:output], result[:content])
88
- puts "✅ Content saved to #{options[:output]}"
89
- else
90
- puts "Content:"
91
- puts result[:content]
77
+ desc "get FILE_ID", "Get content of a specific file"
78
+ method_option :output, type: :string, aliases: "-o", desc: "Output file path"
79
+ def get(file_id)
80
+ result = tool.get_file_content(file_id)
81
+
82
+ puts "📄 #{result[:name]}"
83
+ puts " Type: #{result[:mime_type]}"
84
+ puts " Size: #{format_bytes(result[:size])}"
85
+ puts
86
+
87
+ if options[:output]
88
+ File.write(options[:output], result[:content])
89
+ puts "✅ Content saved to #{options[:output]}"
90
+ else
91
+ puts "Content:"
92
+ puts result[:content]
93
+ end
94
+ rescue RuntimeError => e
95
+ warn "❌ Failed to get file content: #{e.message}"
96
+ exit 1
92
97
  end
93
- rescue RuntimeError => e
94
- warn "❌ Failed to get file content: #{e.message}"
95
- exit 1
96
- end
97
98
 
98
- private
99
+ private
99
100
 
100
- def tool
101
- @tool ||= GdriveTool.new
102
- end
101
+ def tool
102
+ @tool ||= Service.new
103
+ end
103
104
 
104
- def format_bytes(bytes)
105
- return "Unknown" unless bytes
105
+ def format_bytes(bytes)
106
+ return "Unknown" unless bytes
106
107
 
107
- units = %w[B KB MB GB TB]
108
- size = bytes.to_f
109
- unit_index = 0
108
+ units = %w[B KB MB GB TB]
109
+ size = bytes.to_f
110
+ unit_index = 0
110
111
 
111
- while size >= 1024 && unit_index < units.length - 1
112
- size /= 1024
113
- unit_index += 1
114
- end
112
+ while size >= 1024 && unit_index < units.length - 1
113
+ size /= 1024
114
+ unit_index += 1
115
+ end
115
116
 
116
- "#{size.round(1)} #{units[unit_index]}"
117
+ "#{size.round(1)} #{units[unit_index]}"
118
+ end
117
119
  end
118
120
  end