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.
@@ -0,0 +1,308 @@
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 GcalTool
14
+ SCOPES = [
15
+ "https://www.googleapis.com/auth/calendar.readonly",
16
+ "https://www.googleapis.com/auth/drive.readonly"
17
+ ]
18
+ SCOPE = SCOPES.join(" ")
19
+
20
+ def initialize(skip_auth: false)
21
+ ensure_env!
22
+ @service = Google::Apis::CalendarV3::CalendarService.new
23
+ @service.authorization = authorize unless skip_auth
24
+ end
25
+
26
+ def list_events(start_date: nil, end_date: nil, max_results: 20, calendar_id: "primary")
27
+ # Default to today if no start date provided
28
+ start_time = start_date ? Time.parse("#{start_date} 00:00:00") : Time.now.beginning_of_day
29
+ # Default to 7 days from start if no end date provided
30
+ end_time = end_date ? Time.parse("#{end_date} 23:59:59") : start_time + 7 * 24 * 60 * 60
31
+
32
+ results = @service.list_events(
33
+ calendar_id,
34
+ max_results: max_results,
35
+ single_events: true,
36
+ order_by: "startTime",
37
+ time_min: start_time.utc.iso8601,
38
+ time_max: end_time.utc.iso8601
39
+ )
40
+
41
+ events = results.items.map do |event|
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
+ calendar_id: calendar_id
52
+ }
53
+ end
54
+
55
+ {events: events, count: events.length}
56
+ rescue Google::Apis::Error => e
57
+ raise "Google Calendar API Error: #{e.message}"
58
+ rescue => e
59
+ log_error("list_events", e)
60
+ raise e
61
+ end
62
+
63
+ def list_calendars
64
+ results = @service.list_calendar_lists
65
+
66
+ calendars = results.items.map do |calendar|
67
+ {
68
+ id: calendar.id,
69
+ summary: calendar.summary,
70
+ description: calendar.description,
71
+ time_zone: calendar.time_zone,
72
+ access_role: calendar.access_role,
73
+ primary: calendar.primary
74
+ }
75
+ end
76
+
77
+ {calendars: calendars, count: calendars.length}
78
+ rescue Google::Apis::Error => e
79
+ raise "Google Calendar API Error: #{e.message}"
80
+ rescue => e
81
+ log_error("list_calendars", e)
82
+ raise e
83
+ end
84
+
85
+ def search_events(query, start_date: nil, end_date: nil, max_results: 10)
86
+ # Default to today if no start date provided
87
+ start_time = start_date ? Time.parse("#{start_date} 00:00:00") : Time.now.beginning_of_day
88
+ # Default to 30 days from start if no end date provided
89
+ end_time = end_date ? Time.parse("#{end_date} 23:59:59") : start_time + 30 * 24 * 60 * 60
90
+
91
+ results = @service.list_events(
92
+ "primary",
93
+ q: query,
94
+ max_results: max_results,
95
+ single_events: true,
96
+ order_by: "startTime",
97
+ time_min: start_time.utc.iso8601,
98
+ time_max: end_time.utc.iso8601
99
+ )
100
+
101
+ events = results.items.map do |event|
102
+ {
103
+ id: event.id,
104
+ summary: event.summary,
105
+ description: event.description,
106
+ location: event.location,
107
+ start: format_event_time(event.start),
108
+ end: format_event_time(event.end),
109
+ attendees: format_attendees(event.attendees),
110
+ html_link: event.html_link,
111
+ calendar_id: "primary"
112
+ }
113
+ end
114
+
115
+ {events: events, count: events.length}
116
+ rescue Google::Apis::Error => e
117
+ raise "Google Calendar API Error: #{e.message}"
118
+ rescue => e
119
+ log_error("search_events", e)
120
+ raise e
121
+ end
122
+
123
+ def test_connection
124
+ calendar = @service.get_calendar("primary")
125
+ {
126
+ ok: true,
127
+ user: calendar.summary,
128
+ email: calendar.id
129
+ }
130
+ rescue Google::Apis::Error => e
131
+ raise "Google Calendar API Error: #{e.message}"
132
+ rescue => e
133
+ log_error("test_connection", e)
134
+ raise e
135
+ end
136
+
137
+ def authenticate
138
+ perform_auth_flow
139
+ {success: true}
140
+ rescue => e
141
+ {success: false, error: e.message}
142
+ end
143
+
144
+ def perform_auth_flow
145
+ client_id = Mcpeasy::Config.google_client_id
146
+ client_secret = Mcpeasy::Config.google_client_secret
147
+
148
+ unless client_id && client_secret
149
+ raise "Google credentials not found. Please save your credentials.json file using: mcpz config set_google_credentials <path_to_credentials.json>"
150
+ end
151
+
152
+ # Create credentials using OAuth2 flow with localhost redirect
153
+ redirect_uri = "http://localhost:8080"
154
+ client = Signet::OAuth2::Client.new(
155
+ client_id: client_id,
156
+ client_secret: client_secret,
157
+ scope: SCOPE,
158
+ redirect_uri: redirect_uri,
159
+ authorization_uri: "https://accounts.google.com/o/oauth2/auth",
160
+ token_credential_uri: "https://oauth2.googleapis.com/token"
161
+ )
162
+
163
+ # Generate authorization URL
164
+ url = client.authorization_uri.to_s
165
+
166
+ puts "DEBUG: Client ID: #{client_id[0..20]}..."
167
+ puts "DEBUG: Scope: #{SCOPE}"
168
+ puts "DEBUG: Redirect URI: #{redirect_uri}"
169
+ puts
170
+
171
+ # Start callback server to capture OAuth code
172
+ puts "Starting temporary web server to capture OAuth callback..."
173
+ puts "Opening authorization URL in your default browser..."
174
+ puts url
175
+ puts
176
+
177
+ # Automatically open URL in default browser on macOS/Unix
178
+ if system("which open > /dev/null 2>&1")
179
+ system("open", url)
180
+ else
181
+ puts "Could not automatically open browser. Please copy the URL above manually."
182
+ end
183
+ puts
184
+ puts "Waiting for OAuth callback... (will timeout in 60 seconds)"
185
+
186
+ # Wait for the authorization code with timeout
187
+ code = GoogleAuthServer.capture_auth_code
188
+
189
+ unless code
190
+ raise "Failed to receive authorization code. Please try again."
191
+ end
192
+
193
+ puts "✅ Authorization code received!"
194
+ client.code = code
195
+ client.fetch_access_token!
196
+
197
+ # Save credentials to config
198
+ credentials_data = {
199
+ client_id: client.client_id,
200
+ client_secret: client.client_secret,
201
+ scope: client.scope,
202
+ refresh_token: client.refresh_token,
203
+ access_token: client.access_token,
204
+ expires_at: client.expires_at
205
+ }
206
+
207
+ Mcpeasy::Config.save_google_token(credentials_data)
208
+ puts "✅ Authentication successful! Token saved to config"
209
+
210
+ client
211
+ rescue => e
212
+ log_error("perform_auth_flow", e)
213
+ raise "Authentication flow failed: #{e.message}"
214
+ end
215
+
216
+ private
217
+
218
+ def authorize
219
+ credentials_data = Mcpeasy::Config.google_token
220
+ unless credentials_data
221
+ raise <<~ERROR
222
+ Google Calendar authentication required!
223
+ Run the auth command first:
224
+ mcpz gcal auth
225
+ ERROR
226
+ end
227
+
228
+ client = Signet::OAuth2::Client.new(
229
+ client_id: credentials_data.client_id,
230
+ client_secret: credentials_data.client_secret,
231
+ scope: credentials_data.scope.respond_to?(:to_a) ? credentials_data.scope.to_a.join(" ") : credentials_data.scope.to_s,
232
+ refresh_token: credentials_data.refresh_token,
233
+ access_token: credentials_data.access_token,
234
+ token_credential_uri: "https://oauth2.googleapis.com/token"
235
+ )
236
+
237
+ # Check if token needs refresh
238
+ if credentials_data.expires_at
239
+ expires_at = if credentials_data.expires_at.is_a?(String)
240
+ Time.parse(credentials_data.expires_at)
241
+ else
242
+ Time.at(credentials_data.expires_at)
243
+ end
244
+
245
+ if Time.now >= expires_at
246
+ client.refresh!
247
+ # Update saved credentials with new access token
248
+ updated_data = {
249
+ client_id: credentials_data.client_id,
250
+ client_secret: credentials_data.client_secret,
251
+ scope: credentials_data.scope.respond_to?(:to_a) ? credentials_data.scope.to_a.join(" ") : credentials_data.scope.to_s,
252
+ refresh_token: credentials_data.refresh_token,
253
+ access_token: client.access_token,
254
+ expires_at: client.expires_at
255
+ }
256
+ Mcpeasy::Config.save_google_token(updated_data)
257
+ end
258
+ end
259
+
260
+ client
261
+ rescue JSON::ParserError
262
+ raise "Invalid token data. Please re-run: mcpz gcal auth"
263
+ rescue => e
264
+ log_error("authorize", e)
265
+ raise "Authentication failed: #{e.message}"
266
+ end
267
+
268
+ def ensure_env!
269
+ unless Mcpeasy::Config.google_client_id && Mcpeasy::Config.google_client_secret
270
+ raise <<~ERROR
271
+ Google API credentials not configured!
272
+ Please save your Google credentials.json file using:
273
+ mcpz config set_google_credentials <path_to_credentials.json>
274
+ ERROR
275
+ end
276
+ end
277
+
278
+ def format_event_time(event_time)
279
+ return nil unless event_time
280
+
281
+ if event_time.date
282
+ # All-day event
283
+ {date: event_time.date}
284
+ elsif event_time.date_time
285
+ # Specific time event
286
+ {date_time: event_time.date_time.iso8601}
287
+ else
288
+ nil
289
+ end
290
+ end
291
+
292
+ def format_attendees(attendees)
293
+ return [] unless attendees
294
+
295
+ attendees.map do |attendee|
296
+ attendee.email
297
+ end.compact
298
+ end
299
+
300
+ def log_error(method, error)
301
+ Mcpeasy::Config.ensure_config_dirs
302
+ File.write(
303
+ Mcpeasy::Config.log_file_path("gcal", "error"),
304
+ "#{Time.now}: #{method} error: #{error.class}: #{error.message}\n#{error.backtrace.join("\n")}\n",
305
+ mode: "a"
306
+ )
307
+ end
308
+ end
@@ -0,0 +1,381 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "json"
6
+ require_relative "gcal_tool"
7
+
8
+ class MCPServer
9
+ def initialize
10
+ @tools = {
11
+ "test_connection" => {
12
+ name: "test_connection",
13
+ description: "Test the Google Calendar API connection",
14
+ inputSchema: {
15
+ type: "object",
16
+ properties: {},
17
+ required: []
18
+ }
19
+ },
20
+ "list_events" => {
21
+ name: "list_events",
22
+ description: "List calendar events with optional date filtering",
23
+ inputSchema: {
24
+ type: "object",
25
+ properties: {
26
+ start_date: {
27
+ type: "string",
28
+ description: "Start date in YYYY-MM-DD format (default: today)"
29
+ },
30
+ end_date: {
31
+ type: "string",
32
+ description: "End date in YYYY-MM-DD format (default: 7 days from start)"
33
+ },
34
+ max_results: {
35
+ type: "number",
36
+ description: "Maximum number of events to return (default: 20)"
37
+ },
38
+ calendar_id: {
39
+ type: "string",
40
+ description: "Calendar ID to list events from (default: primary calendar)"
41
+ }
42
+ },
43
+ required: []
44
+ }
45
+ },
46
+ "list_calendars" => {
47
+ name: "list_calendars",
48
+ description: "List available calendars",
49
+ inputSchema: {
50
+ type: "object",
51
+ properties: {},
52
+ required: []
53
+ }
54
+ },
55
+ "search_events" => {
56
+ name: "search_events",
57
+ description: "Search for events by text content",
58
+ inputSchema: {
59
+ type: "object",
60
+ properties: {
61
+ query: {
62
+ type: "string",
63
+ description: "Search query to find events"
64
+ },
65
+ start_date: {
66
+ type: "string",
67
+ description: "Start date in YYYY-MM-DD format (default: today)"
68
+ },
69
+ end_date: {
70
+ type: "string",
71
+ description: "End date in YYYY-MM-DD format (default: 30 days from start)"
72
+ },
73
+ max_results: {
74
+ type: "number",
75
+ description: "Maximum number of events to return (default: 10)"
76
+ }
77
+ },
78
+ required: ["query"]
79
+ }
80
+ }
81
+ }
82
+ end
83
+
84
+ def run
85
+ # Disable stdout buffering for immediate response
86
+ $stdout.sync = true
87
+
88
+ # Log startup to file instead of stdout to avoid protocol interference
89
+ Mcpeasy::Config.ensure_config_dirs
90
+ File.write(Mcpeasy::Config.log_file_path("gcal", "startup"), "#{Time.now}: Google Calendar MCP Server starting on stdio\n", mode: "a")
91
+ while (line = $stdin.gets)
92
+ handle_request(line.strip)
93
+ end
94
+ rescue Interrupt
95
+ # Silent shutdown
96
+ rescue => e
97
+ # Log to a file instead of stderr to avoid protocol interference
98
+ File.write(Mcpeasy::Config.log_file_path("gcal", "error"), "#{Time.now}: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
99
+ end
100
+
101
+ private
102
+
103
+ def handle_request(line)
104
+ return if line.empty?
105
+
106
+ begin
107
+ request = JSON.parse(line)
108
+ response = process_request(request)
109
+ if response
110
+ puts JSON.generate(response)
111
+ $stdout.flush
112
+ end
113
+ rescue JSON::ParserError => e
114
+ error_response = {
115
+ jsonrpc: "2.0",
116
+ id: nil,
117
+ error: {
118
+ code: -32700,
119
+ message: "Parse error",
120
+ data: e.message
121
+ }
122
+ }
123
+ puts JSON.generate(error_response)
124
+ $stdout.flush
125
+ rescue => e
126
+ File.write(Mcpeasy::Config.log_file_path("gcal", "error"), "#{Time.now}: Error handling request: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
127
+ error_response = {
128
+ jsonrpc: "2.0",
129
+ id: request&.dig("id"),
130
+ error: {
131
+ code: -32603,
132
+ message: "Internal error",
133
+ data: e.message
134
+ }
135
+ }
136
+ puts JSON.generate(error_response)
137
+ $stdout.flush
138
+ end
139
+ end
140
+
141
+ def process_request(request)
142
+ id = request["id"]
143
+ method = request["method"]
144
+ params = request["params"] || {}
145
+
146
+ case method
147
+ when "notifications/initialized"
148
+ # Client acknowledgment - no response needed
149
+ nil
150
+ when "initialize"
151
+ initialize_response(id, params)
152
+ when "tools/list"
153
+ tools_list_response(id, params)
154
+ when "tools/call"
155
+ tools_call_response(id, params)
156
+ else
157
+ {
158
+ jsonrpc: "2.0",
159
+ id: id,
160
+ error: {
161
+ code: -32601,
162
+ message: "Method not found",
163
+ data: "Unknown method: #{method}"
164
+ }
165
+ }
166
+ end
167
+ end
168
+
169
+ def initialize_response(id, params)
170
+ {
171
+ jsonrpc: "2.0",
172
+ id: id,
173
+ result: {
174
+ protocolVersion: "2024-11-05",
175
+ capabilities: {
176
+ tools: {}
177
+ },
178
+ serverInfo: {
179
+ name: "gcal-mcp-server",
180
+ version: "1.0.0"
181
+ }
182
+ }
183
+ }
184
+ end
185
+
186
+ def tools_list_response(id, params)
187
+ {
188
+ jsonrpc: "2.0",
189
+ id: id,
190
+ result: {
191
+ tools: @tools.values
192
+ }
193
+ }
194
+ end
195
+
196
+ def tools_call_response(id, params)
197
+ tool_name = params["name"]
198
+ arguments = params["arguments"] || {}
199
+
200
+ unless @tools.key?(tool_name)
201
+ return {
202
+ jsonrpc: "2.0",
203
+ id: id,
204
+ error: {
205
+ code: -32602,
206
+ message: "Unknown tool",
207
+ data: "Tool '#{tool_name}' not found"
208
+ }
209
+ }
210
+ end
211
+
212
+ begin
213
+ result = call_tool(tool_name, arguments)
214
+ {
215
+ jsonrpc: "2.0",
216
+ id: id,
217
+ result: {
218
+ content: [
219
+ {
220
+ type: "text",
221
+ text: result
222
+ }
223
+ ],
224
+ isError: false
225
+ }
226
+ }
227
+ rescue => e
228
+ File.write(Mcpeasy::Config.log_file_path("gcal", "error"), "#{Time.now}: Tool error: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
229
+ {
230
+ jsonrpc: "2.0",
231
+ id: id,
232
+ result: {
233
+ content: [
234
+ {
235
+ type: "text",
236
+ text: "❌ Error: #{e.message}"
237
+ }
238
+ ],
239
+ isError: true
240
+ }
241
+ }
242
+ end
243
+ end
244
+
245
+ def call_tool(tool_name, arguments)
246
+ case tool_name
247
+ when "test_connection"
248
+ test_connection
249
+ when "list_events"
250
+ list_events(arguments)
251
+ when "list_calendars"
252
+ list_calendars(arguments)
253
+ when "search_events"
254
+ search_events(arguments)
255
+ else
256
+ raise "Unknown tool: #{tool_name}"
257
+ end
258
+ end
259
+
260
+ def test_connection
261
+ tool = GcalTool.new
262
+ response = tool.test_connection
263
+ if response[:ok]
264
+ "✅ Successfully connected to Google Calendar.\n" \
265
+ " User: #{response[:user]} (#{response[:email]})"
266
+ else
267
+ raise "Connection test failed"
268
+ end
269
+ end
270
+
271
+ def list_events(arguments)
272
+ start_date = arguments["start_date"]
273
+ end_date = arguments["end_date"]
274
+ max_results = arguments["max_results"]&.to_i || 20
275
+ calendar_id = arguments["calendar_id"] || "primary"
276
+
277
+ tool = GcalTool.new
278
+ result = tool.list_events(
279
+ start_date: start_date,
280
+ end_date: end_date,
281
+ max_results: max_results,
282
+ calendar_id: calendar_id
283
+ )
284
+ events = result[:events]
285
+
286
+ if events.empty?
287
+ "📅 No events found for the specified date range"
288
+ else
289
+ output = "📅 Found #{result[:count]} event(s):\n\n"
290
+ events.each_with_index do |event, index|
291
+ output << "#{index + 1}. **#{event[:summary] || "No title"}**\n"
292
+ output << " - Start: #{format_datetime(event[:start])}\n"
293
+ output << " - End: #{format_datetime(event[:end])}\n"
294
+ output << " - Description: #{event[:description] || "No description"}\n" if event[:description]
295
+ output << " - Location: #{event[:location]}\n" if event[:location]
296
+ output << " - Attendees: #{event[:attendees].join(", ")}\n" if event[:attendees]&.any?
297
+ output << " - Link: #{event[:html_link]}\n\n"
298
+ end
299
+ output
300
+ end
301
+ end
302
+
303
+ def list_calendars(arguments)
304
+ tool = GcalTool.new
305
+ result = tool.list_calendars
306
+ calendars = result[:calendars]
307
+
308
+ if calendars.empty?
309
+ "📋 No calendars found"
310
+ else
311
+ output = "📋 Found #{result[:count]} calendar(s):\n\n"
312
+ calendars.each_with_index do |calendar, index|
313
+ output << "#{index + 1}. **#{calendar[:summary]}**\n"
314
+ output << " - ID: `#{calendar[:id]}`\n"
315
+ output << " - Description: #{calendar[:description]}\n" if calendar[:description]
316
+ output << " - Time Zone: #{calendar[:time_zone]}\n"
317
+ output << " - Access Role: #{calendar[:access_role]}\n"
318
+ output << " - Primary: Yes\n" if calendar[:primary]
319
+ output << "\n"
320
+ end
321
+ output
322
+ end
323
+ end
324
+
325
+ def search_events(arguments)
326
+ unless arguments["query"]
327
+ raise "Missing required argument: query"
328
+ end
329
+
330
+ query = arguments["query"].to_s
331
+ start_date = arguments["start_date"]
332
+ end_date = arguments["end_date"]
333
+ max_results = arguments["max_results"]&.to_i || 10
334
+
335
+ tool = GcalTool.new
336
+ result = tool.search_events(
337
+ query,
338
+ start_date: start_date,
339
+ end_date: end_date,
340
+ max_results: max_results
341
+ )
342
+ events = result[:events]
343
+
344
+ if events.empty?
345
+ "🔍 No events found matching '#{query}'"
346
+ else
347
+ output = "🔍 Found #{result[:count]} event(s) matching '#{query}':\n\n"
348
+ events.each_with_index do |event, index|
349
+ output << "#{index + 1}. **#{event[:summary] || "No title"}**\n"
350
+ output << " - Start: #{format_datetime(event[:start])}\n"
351
+ output << " - End: #{format_datetime(event[:end])}\n"
352
+ output << " - Description: #{event[:description] || "No description"}\n" if event[:description]
353
+ output << " - Location: #{event[:location]}\n" if event[:location]
354
+ output << " - Calendar: #{event[:calendar_id]}\n"
355
+ output << " - Link: #{event[:html_link]}\n\n"
356
+ end
357
+ output
358
+ end
359
+ end
360
+
361
+ private
362
+
363
+ def format_datetime(datetime_info)
364
+ return "Unknown" unless datetime_info
365
+
366
+ if datetime_info[:date]
367
+ # All-day event
368
+ datetime_info[:date]
369
+ elsif datetime_info[:date_time]
370
+ # Specific time event
371
+ time = Time.parse(datetime_info[:date_time])
372
+ time.strftime("%Y-%m-%d %H:%M")
373
+ else
374
+ "Unknown"
375
+ end
376
+ end
377
+ end
378
+
379
+ if __FILE__ == $0
380
+ MCPServer.new.run
381
+ end