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,547 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "google/apis/gmail_v1"
5
+ require "googleauth"
6
+ require "signet/oauth_2/client"
7
+ require "fileutils"
8
+ require "json"
9
+ require "time"
10
+ require "mail"
11
+ require "html2text"
12
+ require_relative "../_google/auth_server"
13
+ require_relative "../../mcpeasy/config"
14
+
15
+ module Gmail
16
+ class Service
17
+ SCOPES = [
18
+ "https://www.googleapis.com/auth/gmail.readonly",
19
+ "https://www.googleapis.com/auth/gmail.send",
20
+ "https://www.googleapis.com/auth/gmail.modify"
21
+ ]
22
+ SCOPE = SCOPES.join(" ")
23
+
24
+ def initialize(skip_auth: false)
25
+ ensure_env!
26
+ @service = Google::Apis::GmailV1::GmailService.new
27
+ @service.authorization = authorize unless skip_auth
28
+ end
29
+
30
+ def list_emails(start_date: nil, end_date: nil, max_results: 20, sender: nil, subject: nil, labels: nil, read_status: nil)
31
+ query_parts = []
32
+
33
+ # Date filtering
34
+ if start_date
35
+ query_parts << "after:#{start_date}"
36
+ end
37
+ if end_date
38
+ query_parts << "before:#{end_date}"
39
+ end
40
+
41
+ # Sender filtering
42
+ if sender
43
+ query_parts << "from:#{sender}"
44
+ end
45
+
46
+ # Subject filtering
47
+ if subject
48
+ query_parts << "subject:\"#{subject}\""
49
+ end
50
+
51
+ # Labels filtering
52
+ if labels && !labels.empty?
53
+ if labels.is_a?(Array)
54
+ labels.each { |label| query_parts << "label:#{label}" }
55
+ else
56
+ query_parts << "label:#{labels}"
57
+ end
58
+ end
59
+
60
+ # Read/unread status
61
+ case read_status&.downcase
62
+ when "unread"
63
+ query_parts << "is:unread"
64
+ when "read"
65
+ query_parts << "is:read"
66
+ end
67
+
68
+ query = query_parts.join(" ")
69
+
70
+ results = @service.list_user_messages(
71
+ "me",
72
+ q: query.empty? ? nil : query,
73
+ max_results: max_results
74
+ )
75
+
76
+ emails = (results.messages || []).map do |message|
77
+ get_email_summary(message.id)
78
+ end.compact
79
+
80
+ {emails: emails, count: emails.length}
81
+ rescue Google::Apis::Error => e
82
+ raise "Gmail API Error: #{e.message}"
83
+ rescue => e
84
+ log_error("list_emails", e)
85
+ raise e
86
+ end
87
+
88
+ def search_emails(query, max_results: 10)
89
+ results = @service.list_user_messages(
90
+ "me",
91
+ q: query,
92
+ max_results: max_results
93
+ )
94
+
95
+ emails = (results.messages || []).map do |message|
96
+ get_email_summary(message.id)
97
+ end.compact
98
+
99
+ {emails: emails, count: emails.length}
100
+ rescue Google::Apis::Error => e
101
+ raise "Gmail API Error: #{e.message}"
102
+ rescue => e
103
+ log_error("search_emails", e)
104
+ raise e
105
+ end
106
+
107
+ def get_email_content(email_id)
108
+ message = @service.get_user_message("me", email_id)
109
+
110
+ # Extract email metadata
111
+ headers = extract_headers(message.payload)
112
+
113
+ # Extract email body
114
+ body_data = extract_body(message.payload)
115
+
116
+ {
117
+ id: message.id,
118
+ thread_id: message.thread_id,
119
+ subject: headers["Subject"],
120
+ from: headers["From"],
121
+ to: headers["To"],
122
+ cc: headers["Cc"],
123
+ bcc: headers["Bcc"],
124
+ date: headers["Date"],
125
+ body: body_data[:text],
126
+ body_html: body_data[:html],
127
+ snippet: message.snippet,
128
+ labels: message.label_ids || [],
129
+ attachments: extract_attachments(message.payload)
130
+ }
131
+ rescue Google::Apis::Error => e
132
+ raise "Gmail API Error: #{e.message}"
133
+ rescue => e
134
+ log_error("get_email_content", e)
135
+ raise e
136
+ end
137
+
138
+ def send_email(to:, subject:, body:, cc: nil, bcc: nil, reply_to: nil)
139
+ mail = Mail.new do
140
+ to to
141
+ cc cc if cc
142
+ bcc bcc if bcc
143
+ subject subject
144
+ body body
145
+ reply_to reply_to if reply_to
146
+ end
147
+
148
+ raw_message = mail.to_s
149
+ encoded_message = Base64.urlsafe_encode64(raw_message)
150
+
151
+ message_object = Google::Apis::GmailV1::Message.new(raw: encoded_message)
152
+ result = @service.send_user_message("me", message_object)
153
+
154
+ {
155
+ success: true,
156
+ message_id: result.id,
157
+ thread_id: result.thread_id
158
+ }
159
+ rescue Google::Apis::Error => e
160
+ raise "Gmail API Error: #{e.message}"
161
+ rescue => e
162
+ log_error("send_email", e)
163
+ raise e
164
+ end
165
+
166
+ def reply_to_email(email_id:, body:, include_quoted: true)
167
+ # Get the original message to extract reply information
168
+ original = @service.get_user_message("me", email_id)
169
+ headers = extract_headers(original.payload)
170
+
171
+ # Build reply headers
172
+ original_subject = headers["Subject"] || ""
173
+ reply_subject = original_subject.start_with?("Re:") ? original_subject : "Re: #{original_subject}"
174
+
175
+ reply_to = headers["Reply-To"] || headers["From"]
176
+ message_id = headers["Message-ID"]
177
+
178
+ # Prepare reply body
179
+ reply_body = body
180
+ if include_quoted
181
+ original_body = extract_body(original.payload)[:text]
182
+ date_str = headers["Date"]
183
+ from_str = headers["From"]
184
+
185
+ reply_body += "\n\n"
186
+ reply_body += "On #{date_str}, #{from_str} wrote:\n"
187
+ reply_body += original_body.split("\n").map { |line| "> #{line}" }.join("\n")
188
+ end
189
+
190
+ mail = Mail.new do
191
+ to reply_to
192
+ subject reply_subject
193
+ body reply_body
194
+ in_reply_to message_id if message_id
195
+ references message_id if message_id
196
+ end
197
+
198
+ raw_message = mail.to_s
199
+ encoded_message = Base64.urlsafe_encode64(raw_message)
200
+
201
+ message_object = Google::Apis::GmailV1::Message.new(
202
+ raw: encoded_message,
203
+ thread_id: original.thread_id
204
+ )
205
+
206
+ result = @service.send_user_message("me", message_object)
207
+
208
+ {
209
+ success: true,
210
+ message_id: result.id,
211
+ thread_id: result.thread_id
212
+ }
213
+ rescue Google::Apis::Error => e
214
+ raise "Gmail API Error: #{e.message}"
215
+ rescue => e
216
+ log_error("reply_to_email", e)
217
+ raise e
218
+ end
219
+
220
+ def mark_as_read(email_id)
221
+ modify_message(email_id, remove_label_ids: ["UNREAD"])
222
+ end
223
+
224
+ def mark_as_unread(email_id)
225
+ modify_message(email_id, add_label_ids: ["UNREAD"])
226
+ end
227
+
228
+ def add_label(email_id, label)
229
+ label_id = resolve_label_id(label)
230
+ modify_message(email_id, add_label_ids: [label_id])
231
+ end
232
+
233
+ def remove_label(email_id, label)
234
+ label_id = resolve_label_id(label)
235
+ modify_message(email_id, remove_label_ids: [label_id])
236
+ end
237
+
238
+ def archive_email(email_id)
239
+ modify_message(email_id, remove_label_ids: ["INBOX"])
240
+ end
241
+
242
+ def trash_email(email_id)
243
+ @service.trash_user_message("me", email_id)
244
+ {success: true}
245
+ rescue Google::Apis::Error => e
246
+ raise "Gmail API Error: #{e.message}"
247
+ rescue => e
248
+ log_error("trash_email", e)
249
+ raise e
250
+ end
251
+
252
+ def test_connection
253
+ profile = @service.get_user_profile("me")
254
+ {
255
+ ok: true,
256
+ email: profile.email_address,
257
+ messages_total: profile.messages_total,
258
+ threads_total: profile.threads_total
259
+ }
260
+ rescue Google::Apis::Error => e
261
+ raise "Gmail API Error: #{e.message}"
262
+ rescue => e
263
+ log_error("test_connection", e)
264
+ raise e
265
+ end
266
+
267
+ def authenticate
268
+ perform_auth_flow
269
+ {success: true}
270
+ rescue => e
271
+ {success: false, error: e.message}
272
+ end
273
+
274
+ def perform_auth_flow
275
+ client_id = Mcpeasy::Config.google_client_id
276
+ client_secret = Mcpeasy::Config.google_client_secret
277
+
278
+ unless client_id && client_secret
279
+ raise "Google credentials not found. Please save your credentials.json file using: mcpz config set_google_credentials <path_to_credentials.json>"
280
+ end
281
+
282
+ # Create credentials using OAuth2 flow with localhost redirect
283
+ redirect_uri = "http://localhost:8080"
284
+ client = Signet::OAuth2::Client.new(
285
+ client_id: client_id,
286
+ client_secret: client_secret,
287
+ scope: SCOPE,
288
+ redirect_uri: redirect_uri,
289
+ authorization_uri: "https://accounts.google.com/o/oauth2/auth",
290
+ token_credential_uri: "https://oauth2.googleapis.com/token"
291
+ )
292
+
293
+ # Generate authorization URL
294
+ url = client.authorization_uri.to_s
295
+
296
+ puts "DEBUG: Client ID: #{client_id[0..20]}..."
297
+ puts "DEBUG: Scope: #{SCOPE}"
298
+ puts "DEBUG: Redirect URI: #{redirect_uri}"
299
+ puts
300
+
301
+ # Start callback server to capture OAuth code
302
+ puts "Starting temporary web server to capture OAuth callback..."
303
+ puts "Opening authorization URL in your default browser..."
304
+ puts url
305
+ puts
306
+
307
+ # Automatically open URL in default browser on macOS/Unix
308
+ if system("which open > /dev/null 2>&1")
309
+ system("open", url)
310
+ else
311
+ puts "Could not automatically open browser. Please copy the URL above manually."
312
+ end
313
+ puts
314
+ puts "Waiting for OAuth callback... (will timeout in 60 seconds)"
315
+
316
+ # Wait for the authorization code with timeout
317
+ code = GoogleAuthServer.capture_auth_code
318
+
319
+ unless code
320
+ raise "Failed to receive authorization code. Please try again."
321
+ end
322
+
323
+ puts "✅ Authorization code received!"
324
+ client.code = code
325
+ client.fetch_access_token!
326
+
327
+ # Save credentials to config
328
+ credentials_data = {
329
+ client_id: client.client_id,
330
+ client_secret: client.client_secret,
331
+ scope: client.scope,
332
+ refresh_token: client.refresh_token,
333
+ access_token: client.access_token,
334
+ expires_at: client.expires_at
335
+ }
336
+
337
+ Mcpeasy::Config.save_google_token(credentials_data)
338
+ puts "✅ Authentication successful! Token saved to config"
339
+
340
+ client
341
+ rescue => e
342
+ log_error("perform_auth_flow", e)
343
+ raise "Authentication flow failed: #{e.message}"
344
+ end
345
+
346
+ private
347
+
348
+ def authorize
349
+ credentials_data = Mcpeasy::Config.google_token
350
+ unless credentials_data
351
+ raise <<~ERROR
352
+ Gmail authentication required!
353
+ Run the auth command first:
354
+ mcpz gmail auth
355
+ ERROR
356
+ end
357
+
358
+ client = Signet::OAuth2::Client.new(
359
+ client_id: credentials_data.client_id,
360
+ client_secret: credentials_data.client_secret,
361
+ scope: credentials_data.scope.respond_to?(:to_a) ? credentials_data.scope.to_a.join(" ") : credentials_data.scope.to_s,
362
+ refresh_token: credentials_data.refresh_token,
363
+ access_token: credentials_data.access_token,
364
+ token_credential_uri: "https://oauth2.googleapis.com/token"
365
+ )
366
+
367
+ # Check if token needs refresh
368
+ if credentials_data.expires_at
369
+ expires_at = if credentials_data.expires_at.is_a?(String)
370
+ Time.parse(credentials_data.expires_at)
371
+ else
372
+ Time.at(credentials_data.expires_at)
373
+ end
374
+
375
+ if Time.now >= expires_at
376
+ client.refresh!
377
+ # Update saved credentials with new access token
378
+ updated_data = {
379
+ client_id: credentials_data.client_id,
380
+ client_secret: credentials_data.client_secret,
381
+ scope: credentials_data.scope.respond_to?(:to_a) ? credentials_data.scope.to_a.join(" ") : credentials_data.scope.to_s,
382
+ refresh_token: credentials_data.refresh_token,
383
+ access_token: client.access_token,
384
+ expires_at: client.expires_at
385
+ }
386
+ Mcpeasy::Config.save_google_token(updated_data)
387
+ end
388
+ end
389
+
390
+ client
391
+ rescue JSON::ParserError
392
+ raise "Invalid token data. Please re-run: mcpz gmail auth"
393
+ rescue => e
394
+ log_error("authorize", e)
395
+ raise "Authentication failed: #{e.message}"
396
+ end
397
+
398
+ def ensure_env!
399
+ unless Mcpeasy::Config.google_client_id && Mcpeasy::Config.google_client_secret
400
+ raise <<~ERROR
401
+ Google API credentials not configured!
402
+ Please save your Google credentials.json file using:
403
+ mcpz config set_google_credentials <path_to_credentials.json>
404
+ ERROR
405
+ end
406
+ end
407
+
408
+ def get_email_summary(message_id)
409
+ message = @service.get_user_message("me", message_id, format: "metadata")
410
+ headers = extract_headers(message.payload)
411
+
412
+ {
413
+ id: message.id,
414
+ thread_id: message.thread_id,
415
+ subject: headers["Subject"],
416
+ from: headers["From"],
417
+ date: headers["Date"],
418
+ snippet: message.snippet,
419
+ labels: message.label_ids || []
420
+ }
421
+ rescue => e
422
+ log_error("get_email_summary", e)
423
+ nil
424
+ end
425
+
426
+ def extract_headers(payload)
427
+ headers = {}
428
+ (payload.headers || []).each do |header|
429
+ headers[header.name] = header.value
430
+ end
431
+ headers
432
+ end
433
+
434
+ def extract_body(payload)
435
+ body_data = {text: "", html: ""}
436
+
437
+ if payload.body&.data
438
+ # Simple message body
439
+ decoded_body = Base64.urlsafe_decode64(payload.body.data)
440
+ if payload.mime_type == "text/html"
441
+ body_data[:html] = decoded_body
442
+ body_data[:text] = Html2Text.convert(decoded_body)
443
+ else
444
+ body_data[:text] = decoded_body
445
+ end
446
+ elsif payload.parts
447
+ # Multipart message
448
+ extract_body_from_parts(payload.parts, body_data)
449
+ end
450
+
451
+ body_data
452
+ end
453
+
454
+ def extract_body_from_parts(parts, body_data)
455
+ parts.each do |part|
456
+ if part.mime_type == "text/plain" && part.body&.data
457
+ body_data[:text] += Base64.urlsafe_decode64(part.body.data)
458
+ elsif part.mime_type == "text/html" && part.body&.data
459
+ html_content = Base64.urlsafe_decode64(part.body.data)
460
+ body_data[:html] += html_content
461
+ # If we don't have plain text yet, convert from HTML
462
+ if body_data[:text].empty?
463
+ body_data[:text] = Html2Text.convert(html_content)
464
+ end
465
+ elsif part.parts
466
+ # Nested multipart
467
+ extract_body_from_parts(part.parts, body_data)
468
+ end
469
+ end
470
+ end
471
+
472
+ def extract_attachments(payload)
473
+ attachments = []
474
+
475
+ if payload.parts
476
+ extract_attachments_from_parts(payload.parts, attachments)
477
+ end
478
+
479
+ attachments
480
+ end
481
+
482
+ def extract_attachments_from_parts(parts, attachments)
483
+ parts.each do |part|
484
+ if part.filename && !part.filename.empty?
485
+ attachments << {
486
+ filename: part.filename,
487
+ mime_type: part.mime_type,
488
+ size: part.body&.size,
489
+ attachment_id: part.body&.attachment_id
490
+ }
491
+ elsif part.parts
492
+ extract_attachments_from_parts(part.parts, attachments)
493
+ end
494
+ end
495
+ end
496
+
497
+ def modify_message(email_id, add_label_ids: [], remove_label_ids: [])
498
+ request = Google::Apis::GmailV1::ModifyMessageRequest.new(
499
+ add_label_ids: add_label_ids,
500
+ remove_label_ids: remove_label_ids
501
+ )
502
+
503
+ @service.modify_message("me", email_id, request)
504
+ {success: true}
505
+ rescue Google::Apis::Error => e
506
+ raise "Gmail API Error: #{e.message}"
507
+ rescue => e
508
+ log_error("modify_message", e)
509
+ raise e
510
+ end
511
+
512
+ def resolve_label_id(label)
513
+ # Handle common label names
514
+ case label.upcase
515
+ when "INBOX"
516
+ "INBOX"
517
+ when "SENT"
518
+ "SENT"
519
+ when "DRAFT"
520
+ "DRAFT"
521
+ when "SPAM"
522
+ "SPAM"
523
+ when "TRASH"
524
+ "TRASH"
525
+ when "UNREAD"
526
+ "UNREAD"
527
+ when "STARRED"
528
+ "STARRED"
529
+ when "IMPORTANT"
530
+ "IMPORTANT"
531
+ else
532
+ # For custom labels, we'd need to fetch the labels list
533
+ # For now, return the label as-is and let the API handle it
534
+ label
535
+ end
536
+ end
537
+
538
+ def log_error(method, error)
539
+ Mcpeasy::Config.ensure_config_dirs
540
+ File.write(
541
+ Mcpeasy::Config.log_file_path("gmail", "error"),
542
+ "#{Time.now}: #{method} error: #{error.class}: #{error.message}\n#{error.backtrace.join("\n")}\n",
543
+ mode: "a"
544
+ )
545
+ end
546
+ end
547
+ end