mcpeasy 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/.claude/settings.json +15 -0
- data/.claudeignore +4 -0
- data/.envrc +2 -0
- data/.mcp.json +40 -0
- data/CLAUDE.md +170 -0
- data/README.md +161 -0
- data/bin/mcpz +6 -0
- data/env.template +11 -0
- data/ext/setup.rb +7 -0
- data/lib/mcpeasy/cli.rb +154 -0
- data/lib/mcpeasy/config.rb +102 -0
- data/lib/mcpeasy/setup.rb +22 -0
- data/lib/mcpeasy/version.rb +5 -0
- data/lib/mcpeasy.rb +9 -0
- data/lib/utilities/_google/auth_server.rb +149 -0
- data/lib/utilities/gcal/README.md +237 -0
- data/lib/utilities/gcal/cli.rb +134 -0
- data/lib/utilities/gcal/gcal_tool.rb +308 -0
- data/lib/utilities/gcal/mcp.rb +381 -0
- data/lib/utilities/gdrive/README.md +269 -0
- data/lib/utilities/gdrive/cli.rb +118 -0
- data/lib/utilities/gdrive/gdrive_tool.rb +291 -0
- data/lib/utilities/gdrive/mcp.rb +347 -0
- data/lib/utilities/gmeet/README.md +133 -0
- data/lib/utilities/gmeet/cli.rb +157 -0
- data/lib/utilities/gmeet/gmeet_tool.rb +407 -0
- data/lib/utilities/gmeet/mcp.rb +438 -0
- data/lib/utilities/slack/README.md +211 -0
- data/lib/utilities/slack/cli.rb +74 -0
- data/lib/utilities/slack/mcp.rb +280 -0
- data/lib/utilities/slack/slack_tool.rb +119 -0
- data/logs/.keep +0 -0
- data/mcpeasy.gemspec +47 -0
- metadata +191 -0
@@ -0,0 +1,407 @@
|
|
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
|
+
class GmeetTool
|
14
|
+
SCOPE = "https://www.googleapis.com/auth/calendar.readonly"
|
15
|
+
|
16
|
+
def initialize(skip_auth: false)
|
17
|
+
ensure_env!
|
18
|
+
@service = Google::Apis::CalendarV3::CalendarService.new
|
19
|
+
@service.authorization = authorize unless skip_auth
|
20
|
+
end
|
21
|
+
|
22
|
+
def list_meetings(start_date: nil, end_date: nil, max_results: 20, calendar_id: "primary")
|
23
|
+
# Default to today if no start date provided
|
24
|
+
start_time = start_date ? Time.parse("#{start_date} 00:00:00") : Time.now.beginning_of_day
|
25
|
+
# Default to 7 days from start if no end date provided
|
26
|
+
end_time = end_date ? Time.parse("#{end_date} 23:59:59") : start_time + 7 * 24 * 60 * 60
|
27
|
+
|
28
|
+
results = @service.list_events(
|
29
|
+
calendar_id,
|
30
|
+
max_results: max_results,
|
31
|
+
single_events: true,
|
32
|
+
order_by: "startTime",
|
33
|
+
time_min: start_time.utc.iso8601,
|
34
|
+
time_max: end_time.utc.iso8601
|
35
|
+
)
|
36
|
+
|
37
|
+
# Filter for events that have Google Meet links
|
38
|
+
meetings = results.items.filter_map do |event|
|
39
|
+
meet_link = extract_meet_link(event)
|
40
|
+
next unless meet_link
|
41
|
+
|
42
|
+
{
|
43
|
+
id: event.id,
|
44
|
+
summary: event.summary,
|
45
|
+
description: event.description,
|
46
|
+
location: event.location,
|
47
|
+
start: format_event_time(event.start),
|
48
|
+
end: format_event_time(event.end),
|
49
|
+
attendees: format_attendees(event.attendees),
|
50
|
+
html_link: event.html_link,
|
51
|
+
meet_link: meet_link,
|
52
|
+
calendar_id: calendar_id
|
53
|
+
}
|
54
|
+
end
|
55
|
+
|
56
|
+
{meetings: meetings, count: meetings.length}
|
57
|
+
rescue Google::Apis::Error => e
|
58
|
+
raise "Google Calendar API Error: #{e.message}"
|
59
|
+
rescue => e
|
60
|
+
log_error("list_meetings", e)
|
61
|
+
raise e
|
62
|
+
end
|
63
|
+
|
64
|
+
def upcoming_meetings(max_results: 10, calendar_id: "primary")
|
65
|
+
# Get meetings starting from now
|
66
|
+
start_time = Time.now
|
67
|
+
# Look ahead 24 hours by default
|
68
|
+
end_time = start_time + 24 * 60 * 60
|
69
|
+
|
70
|
+
results = @service.list_events(
|
71
|
+
calendar_id,
|
72
|
+
max_results: max_results,
|
73
|
+
single_events: true,
|
74
|
+
order_by: "startTime",
|
75
|
+
time_min: start_time.utc.iso8601,
|
76
|
+
time_max: end_time.utc.iso8601
|
77
|
+
)
|
78
|
+
|
79
|
+
# Filter for events that have Google Meet links and are upcoming
|
80
|
+
meetings = results.items.filter_map do |event|
|
81
|
+
meet_link = extract_meet_link(event)
|
82
|
+
next unless meet_link
|
83
|
+
|
84
|
+
event_start_time = event.start.date_time&.to_time || (event.start.date ? Time.parse(event.start.date) : nil)
|
85
|
+
next unless event_start_time && event_start_time >= start_time
|
86
|
+
|
87
|
+
{
|
88
|
+
id: event.id,
|
89
|
+
summary: event.summary,
|
90
|
+
description: event.description,
|
91
|
+
location: event.location,
|
92
|
+
start: format_event_time(event.start),
|
93
|
+
end: format_event_time(event.end),
|
94
|
+
attendees: format_attendees(event.attendees),
|
95
|
+
html_link: event.html_link,
|
96
|
+
meet_link: meet_link,
|
97
|
+
calendar_id: calendar_id,
|
98
|
+
time_until_start: time_until_event(event_start_time)
|
99
|
+
}
|
100
|
+
end
|
101
|
+
|
102
|
+
{meetings: meetings, count: meetings.length}
|
103
|
+
rescue Google::Apis::Error => e
|
104
|
+
raise "Google Calendar API Error: #{e.message}"
|
105
|
+
rescue => e
|
106
|
+
log_error("upcoming_meetings", e)
|
107
|
+
raise e
|
108
|
+
end
|
109
|
+
|
110
|
+
def search_meetings(query, start_date: nil, end_date: nil, max_results: 10)
|
111
|
+
# Default to today if no start date provided
|
112
|
+
start_time = start_date ? Time.parse("#{start_date} 00:00:00") : Time.now.beginning_of_day
|
113
|
+
# Default to 30 days from start if no end date provided
|
114
|
+
end_time = end_date ? Time.parse("#{end_date} 23:59:59") : start_time + 30 * 24 * 60 * 60
|
115
|
+
|
116
|
+
results = @service.list_events(
|
117
|
+
"primary",
|
118
|
+
q: query,
|
119
|
+
max_results: max_results,
|
120
|
+
single_events: true,
|
121
|
+
order_by: "startTime",
|
122
|
+
time_min: start_time.utc.iso8601,
|
123
|
+
time_max: end_time.utc.iso8601
|
124
|
+
)
|
125
|
+
|
126
|
+
# Filter for events that have Google Meet links
|
127
|
+
meetings = results.items.filter_map do |event|
|
128
|
+
meet_link = extract_meet_link(event)
|
129
|
+
next unless meet_link
|
130
|
+
|
131
|
+
{
|
132
|
+
id: event.id,
|
133
|
+
summary: event.summary,
|
134
|
+
description: event.description,
|
135
|
+
location: event.location,
|
136
|
+
start: format_event_time(event.start),
|
137
|
+
end: format_event_time(event.end),
|
138
|
+
attendees: format_attendees(event.attendees),
|
139
|
+
html_link: event.html_link,
|
140
|
+
meet_link: meet_link,
|
141
|
+
calendar_id: "primary"
|
142
|
+
}
|
143
|
+
end
|
144
|
+
|
145
|
+
{meetings: meetings, count: meetings.length}
|
146
|
+
rescue Google::Apis::Error => e
|
147
|
+
raise "Google Calendar API Error: #{e.message}"
|
148
|
+
rescue => e
|
149
|
+
log_error("search_meetings", e)
|
150
|
+
raise e
|
151
|
+
end
|
152
|
+
|
153
|
+
def get_meeting_url(event_id, calendar_id: "primary")
|
154
|
+
event = @service.get_event(calendar_id, event_id)
|
155
|
+
meet_link = extract_meet_link(event)
|
156
|
+
|
157
|
+
unless meet_link
|
158
|
+
raise "No Google Meet link found for this event"
|
159
|
+
end
|
160
|
+
|
161
|
+
{
|
162
|
+
event_id: event_id,
|
163
|
+
summary: event.summary,
|
164
|
+
meet_link: meet_link,
|
165
|
+
start: format_event_time(event.start),
|
166
|
+
end: format_event_time(event.end)
|
167
|
+
}
|
168
|
+
rescue Google::Apis::Error => e
|
169
|
+
raise "Google Calendar API Error: #{e.message}"
|
170
|
+
rescue => e
|
171
|
+
log_error("get_meeting_url", e)
|
172
|
+
raise e
|
173
|
+
end
|
174
|
+
|
175
|
+
def test_connection
|
176
|
+
calendar = @service.get_calendar("primary")
|
177
|
+
{
|
178
|
+
ok: true,
|
179
|
+
user: calendar.summary,
|
180
|
+
email: calendar.id
|
181
|
+
}
|
182
|
+
rescue Google::Apis::Error => e
|
183
|
+
raise "Google Calendar API Error: #{e.message}"
|
184
|
+
rescue => e
|
185
|
+
log_error("test_connection", e)
|
186
|
+
raise e
|
187
|
+
end
|
188
|
+
|
189
|
+
def authenticate
|
190
|
+
perform_auth_flow
|
191
|
+
{success: true}
|
192
|
+
rescue => e
|
193
|
+
{success: false, error: e.message}
|
194
|
+
end
|
195
|
+
|
196
|
+
def perform_auth_flow
|
197
|
+
client_id = Mcpeasy::Config.google_client_id
|
198
|
+
client_secret = Mcpeasy::Config.google_client_secret
|
199
|
+
|
200
|
+
unless client_id && client_secret
|
201
|
+
raise "Google credentials not found. Please save your credentials.json file using: mcpz config set_google_credentials <path_to_credentials.json>"
|
202
|
+
end
|
203
|
+
|
204
|
+
# Create credentials using OAuth2 flow with localhost redirect
|
205
|
+
redirect_uri = "http://localhost:8080"
|
206
|
+
client = Signet::OAuth2::Client.new(
|
207
|
+
client_id: client_id,
|
208
|
+
client_secret: client_secret,
|
209
|
+
scope: SCOPE,
|
210
|
+
redirect_uri: redirect_uri,
|
211
|
+
authorization_uri: "https://accounts.google.com/o/oauth2/auth",
|
212
|
+
token_credential_uri: "https://oauth2.googleapis.com/token"
|
213
|
+
)
|
214
|
+
|
215
|
+
# Generate authorization URL
|
216
|
+
url = client.authorization_uri.to_s
|
217
|
+
|
218
|
+
puts "DEBUG: Client ID: #{client_id[0..20]}..."
|
219
|
+
puts "DEBUG: Scope: #{SCOPE}"
|
220
|
+
puts "DEBUG: Redirect URI: #{redirect_uri}"
|
221
|
+
puts
|
222
|
+
|
223
|
+
# Start callback server to capture OAuth code
|
224
|
+
puts "Starting temporary web server to capture OAuth callback..."
|
225
|
+
puts "Opening authorization URL in your default browser..."
|
226
|
+
puts url
|
227
|
+
puts
|
228
|
+
|
229
|
+
# Automatically open URL in default browser on macOS/Unix
|
230
|
+
if system("which open > /dev/null 2>&1")
|
231
|
+
system("open", url)
|
232
|
+
else
|
233
|
+
puts "Could not automatically open browser. Please copy the URL above manually."
|
234
|
+
end
|
235
|
+
puts
|
236
|
+
puts "Waiting for OAuth callback... (will timeout in 60 seconds)"
|
237
|
+
|
238
|
+
# Wait for the authorization code with timeout
|
239
|
+
code = GoogleAuthServer.capture_auth_code
|
240
|
+
|
241
|
+
unless code
|
242
|
+
raise "Failed to receive authorization code. Please try again."
|
243
|
+
end
|
244
|
+
|
245
|
+
puts "✅ Authorization code received!"
|
246
|
+
client.code = code
|
247
|
+
client.fetch_access_token!
|
248
|
+
|
249
|
+
# Save credentials to config
|
250
|
+
credentials_data = {
|
251
|
+
client_id: client.client_id,
|
252
|
+
client_secret: client.client_secret,
|
253
|
+
scope: client.scope,
|
254
|
+
refresh_token: client.refresh_token,
|
255
|
+
access_token: client.access_token,
|
256
|
+
expires_at: client.expires_at
|
257
|
+
}
|
258
|
+
|
259
|
+
Mcpeasy::Config.save_google_token(credentials_data)
|
260
|
+
puts "✅ Authentication successful! Token saved to config"
|
261
|
+
|
262
|
+
client
|
263
|
+
rescue => e
|
264
|
+
log_error("perform_auth_flow", e)
|
265
|
+
raise "Authentication flow failed: #{e.message}"
|
266
|
+
end
|
267
|
+
|
268
|
+
private
|
269
|
+
|
270
|
+
def authorize
|
271
|
+
credentials_data = Mcpeasy::Config.google_token
|
272
|
+
unless credentials_data
|
273
|
+
raise <<~ERROR
|
274
|
+
Google Calendar authentication required!
|
275
|
+
Run the auth command first:
|
276
|
+
mcpz gmeet auth
|
277
|
+
ERROR
|
278
|
+
end
|
279
|
+
|
280
|
+
client = Signet::OAuth2::Client.new(
|
281
|
+
client_id: credentials_data.client_id,
|
282
|
+
client_secret: credentials_data.client_secret,
|
283
|
+
scope: credentials_data.scope.respond_to?(:to_a) ? credentials_data.scope.to_a.join(" ") : credentials_data.scope.to_s,
|
284
|
+
refresh_token: credentials_data.refresh_token,
|
285
|
+
access_token: credentials_data.access_token,
|
286
|
+
token_credential_uri: "https://oauth2.googleapis.com/token"
|
287
|
+
)
|
288
|
+
|
289
|
+
# Check if token needs refresh
|
290
|
+
if credentials_data.expires_at
|
291
|
+
expires_at = if credentials_data.expires_at.is_a?(String)
|
292
|
+
Time.parse(credentials_data.expires_at)
|
293
|
+
else
|
294
|
+
Time.at(credentials_data.expires_at)
|
295
|
+
end
|
296
|
+
|
297
|
+
if Time.now >= expires_at
|
298
|
+
client.refresh!
|
299
|
+
# Update saved credentials with new access token
|
300
|
+
updated_data = {
|
301
|
+
client_id: credentials_data.client_id,
|
302
|
+
client_secret: credentials_data.client_secret,
|
303
|
+
scope: credentials_data.scope.respond_to?(:to_a) ? credentials_data.scope.to_a.join(" ") : credentials_data.scope.to_s,
|
304
|
+
refresh_token: credentials_data.refresh_token,
|
305
|
+
access_token: client.access_token,
|
306
|
+
expires_at: client.expires_at
|
307
|
+
}
|
308
|
+
Mcpeasy::Config.save_google_token(updated_data)
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
client
|
313
|
+
rescue JSON::ParserError
|
314
|
+
raise "Invalid token data. Please re-run: mcpz gmeet auth"
|
315
|
+
rescue => e
|
316
|
+
log_error("authorize", e)
|
317
|
+
raise "Authentication failed: #{e.message}"
|
318
|
+
end
|
319
|
+
|
320
|
+
def ensure_env!
|
321
|
+
unless Mcpeasy::Config.google_client_id && Mcpeasy::Config.google_client_secret
|
322
|
+
raise <<~ERROR
|
323
|
+
Google API credentials not configured!
|
324
|
+
Please save your Google credentials.json file using:
|
325
|
+
mcpz config set_google_credentials <path_to_credentials.json>
|
326
|
+
ERROR
|
327
|
+
end
|
328
|
+
end
|
329
|
+
|
330
|
+
def extract_meet_link(event)
|
331
|
+
# Check various places where Google Meet links might be stored
|
332
|
+
|
333
|
+
# 1. Check conference data (most reliable)
|
334
|
+
if event.conference_data&.conference_solution&.name == "Google Meet"
|
335
|
+
return event.conference_data.entry_points&.find { |ep| ep.entry_point_type == "video" }&.uri
|
336
|
+
end
|
337
|
+
|
338
|
+
# 2. Check hangout link (legacy)
|
339
|
+
return event.hangout_link if event.hangout_link
|
340
|
+
|
341
|
+
# 3. Check description for meet.google.com links
|
342
|
+
if event.description
|
343
|
+
meet_match = event.description.match(/https:\/\/meet\.google\.com\/[a-z-]+/)
|
344
|
+
return meet_match[0] if meet_match
|
345
|
+
end
|
346
|
+
|
347
|
+
# 4. Check location field
|
348
|
+
if event.location
|
349
|
+
meet_match = event.location.match(/https:\/\/meet\.google\.com\/[a-z-]+/)
|
350
|
+
return meet_match[0] if meet_match
|
351
|
+
end
|
352
|
+
|
353
|
+
nil
|
354
|
+
end
|
355
|
+
|
356
|
+
def format_event_time(event_time)
|
357
|
+
return nil unless event_time
|
358
|
+
|
359
|
+
if event_time.date
|
360
|
+
# All-day event
|
361
|
+
{date: event_time.date}
|
362
|
+
elsif event_time.date_time
|
363
|
+
# Specific time event
|
364
|
+
{date_time: event_time.date_time.iso8601}
|
365
|
+
else
|
366
|
+
nil
|
367
|
+
end
|
368
|
+
end
|
369
|
+
|
370
|
+
def format_attendees(attendees)
|
371
|
+
return [] unless attendees
|
372
|
+
|
373
|
+
attendees.map do |attendee|
|
374
|
+
attendee.email
|
375
|
+
end.compact
|
376
|
+
end
|
377
|
+
|
378
|
+
def time_until_event(event_time)
|
379
|
+
now = Time.now
|
380
|
+
event_time = event_time.is_a?(String) ? Time.parse(event_time) : event_time
|
381
|
+
|
382
|
+
diff_seconds = (event_time - now).to_i
|
383
|
+
return "started" if diff_seconds < 0
|
384
|
+
|
385
|
+
if diff_seconds < 60
|
386
|
+
"#{diff_seconds} seconds"
|
387
|
+
elsif diff_seconds < 3600
|
388
|
+
"#{diff_seconds / 60} minutes"
|
389
|
+
elsif diff_seconds < 86400
|
390
|
+
hours = diff_seconds / 3600
|
391
|
+
minutes = (diff_seconds % 3600) / 60
|
392
|
+
"#{hours}h #{minutes}m"
|
393
|
+
else
|
394
|
+
days = diff_seconds / 86400
|
395
|
+
"#{days} days"
|
396
|
+
end
|
397
|
+
end
|
398
|
+
|
399
|
+
def log_error(method, error)
|
400
|
+
Mcpeasy::Config.ensure_config_dirs
|
401
|
+
File.write(
|
402
|
+
Mcpeasy::Config.log_file_path("gmeet", "error"),
|
403
|
+
"#{Time.now}: #{method} error: #{error.class}: #{error.message}\n#{error.backtrace.join("\n")}\n",
|
404
|
+
mode: "a"
|
405
|
+
)
|
406
|
+
end
|
407
|
+
end
|