mcpeasy 0.1.0 → 0.2.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,409 @@
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 Gmeet
14
+ class Service
15
+ SCOPE = "https://www.googleapis.com/auth/calendar.readonly"
16
+
17
+ def initialize(skip_auth: false)
18
+ ensure_env!
19
+ @service = Google::Apis::CalendarV3::CalendarService.new
20
+ @service.authorization = authorize unless skip_auth
21
+ end
22
+
23
+ def list_meetings(start_date: nil, end_date: nil, max_results: 20, calendar_id: "primary")
24
+ # Default to today if no start date provided
25
+ start_time = start_date ? Time.parse("#{start_date} 00:00:00") : Time.now.beginning_of_day
26
+ # Default to 7 days from start if no end date provided
27
+ end_time = end_date ? Time.parse("#{end_date} 23:59:59") : start_time + 7 * 24 * 60 * 60
28
+
29
+ results = @service.list_events(
30
+ calendar_id,
31
+ max_results: max_results,
32
+ single_events: true,
33
+ order_by: "startTime",
34
+ time_min: start_time.utc.iso8601,
35
+ time_max: end_time.utc.iso8601
36
+ )
37
+
38
+ # Filter for events that have Google Meet links
39
+ meetings = results.items.filter_map do |event|
40
+ meet_link = extract_meet_link(event)
41
+ next unless meet_link
42
+
43
+ {
44
+ id: event.id,
45
+ summary: event.summary,
46
+ description: event.description,
47
+ location: event.location,
48
+ start: format_event_time(event.start),
49
+ end: format_event_time(event.end),
50
+ attendees: format_attendees(event.attendees),
51
+ html_link: event.html_link,
52
+ meet_link: meet_link,
53
+ calendar_id: calendar_id
54
+ }
55
+ end
56
+
57
+ {meetings: meetings, count: meetings.length}
58
+ rescue Google::Apis::Error => e
59
+ raise "Google Calendar API Error: #{e.message}"
60
+ rescue => e
61
+ log_error("list_meetings", e)
62
+ raise e
63
+ end
64
+
65
+ def upcoming_meetings(max_results: 10, calendar_id: "primary")
66
+ # Get meetings starting from now
67
+ start_time = Time.now
68
+ # Look ahead 24 hours by default
69
+ end_time = start_time + 24 * 60 * 60
70
+
71
+ results = @service.list_events(
72
+ calendar_id,
73
+ max_results: max_results,
74
+ single_events: true,
75
+ order_by: "startTime",
76
+ time_min: start_time.utc.iso8601,
77
+ time_max: end_time.utc.iso8601
78
+ )
79
+
80
+ # Filter for events that have Google Meet links and are upcoming
81
+ meetings = results.items.filter_map do |event|
82
+ meet_link = extract_meet_link(event)
83
+ next unless meet_link
84
+
85
+ event_start_time = event.start.date_time&.to_time || (event.start.date ? Time.parse(event.start.date) : nil)
86
+ next unless event_start_time && event_start_time >= start_time
87
+
88
+ {
89
+ id: event.id,
90
+ summary: event.summary,
91
+ description: event.description,
92
+ location: event.location,
93
+ start: format_event_time(event.start),
94
+ end: format_event_time(event.end),
95
+ attendees: format_attendees(event.attendees),
96
+ html_link: event.html_link,
97
+ meet_link: meet_link,
98
+ calendar_id: calendar_id,
99
+ time_until_start: time_until_event(event_start_time)
100
+ }
101
+ end
102
+
103
+ {meetings: meetings, count: meetings.length}
104
+ rescue Google::Apis::Error => e
105
+ raise "Google Calendar API Error: #{e.message}"
106
+ rescue => e
107
+ log_error("upcoming_meetings", e)
108
+ raise e
109
+ end
110
+
111
+ def search_meetings(query, start_date: nil, end_date: nil, max_results: 10)
112
+ # Default to today if no start date provided
113
+ start_time = start_date ? Time.parse("#{start_date} 00:00:00") : Time.now.beginning_of_day
114
+ # Default to 30 days from start if no end date provided
115
+ end_time = end_date ? Time.parse("#{end_date} 23:59:59") : start_time + 30 * 24 * 60 * 60
116
+
117
+ results = @service.list_events(
118
+ "primary",
119
+ q: query,
120
+ max_results: max_results,
121
+ single_events: true,
122
+ order_by: "startTime",
123
+ time_min: start_time.utc.iso8601,
124
+ time_max: end_time.utc.iso8601
125
+ )
126
+
127
+ # Filter for events that have Google Meet links
128
+ meetings = results.items.filter_map do |event|
129
+ meet_link = extract_meet_link(event)
130
+ next unless meet_link
131
+
132
+ {
133
+ id: event.id,
134
+ summary: event.summary,
135
+ description: event.description,
136
+ location: event.location,
137
+ start: format_event_time(event.start),
138
+ end: format_event_time(event.end),
139
+ attendees: format_attendees(event.attendees),
140
+ html_link: event.html_link,
141
+ meet_link: meet_link,
142
+ calendar_id: "primary"
143
+ }
144
+ end
145
+
146
+ {meetings: meetings, count: meetings.length}
147
+ rescue Google::Apis::Error => e
148
+ raise "Google Calendar API Error: #{e.message}"
149
+ rescue => e
150
+ log_error("search_meetings", e)
151
+ raise e
152
+ end
153
+
154
+ def get_meeting_url(event_id, calendar_id: "primary")
155
+ event = @service.get_event(calendar_id, event_id)
156
+ meet_link = extract_meet_link(event)
157
+
158
+ unless meet_link
159
+ raise "No Google Meet link found for this event"
160
+ end
161
+
162
+ {
163
+ event_id: event_id,
164
+ summary: event.summary,
165
+ meet_link: meet_link,
166
+ start: format_event_time(event.start),
167
+ end: format_event_time(event.end)
168
+ }
169
+ rescue Google::Apis::Error => e
170
+ raise "Google Calendar API Error: #{e.message}"
171
+ rescue => e
172
+ log_error("get_meeting_url", e)
173
+ raise e
174
+ end
175
+
176
+ def test_connection
177
+ calendar = @service.get_calendar("primary")
178
+ {
179
+ ok: true,
180
+ user: calendar.summary,
181
+ email: calendar.id
182
+ }
183
+ rescue Google::Apis::Error => e
184
+ raise "Google Calendar API Error: #{e.message}"
185
+ rescue => e
186
+ log_error("test_connection", e)
187
+ raise e
188
+ end
189
+
190
+ def authenticate
191
+ perform_auth_flow
192
+ {success: true}
193
+ rescue => e
194
+ {success: false, error: e.message}
195
+ end
196
+
197
+ def perform_auth_flow
198
+ client_id = Mcpeasy::Config.google_client_id
199
+ client_secret = Mcpeasy::Config.google_client_secret
200
+
201
+ unless client_id && client_secret
202
+ raise "Google credentials not found. Please save your credentials.json file using: mcpz config set_google_credentials <path_to_credentials.json>"
203
+ end
204
+
205
+ # Create credentials using OAuth2 flow with localhost redirect
206
+ redirect_uri = "http://localhost:8080"
207
+ client = Signet::OAuth2::Client.new(
208
+ client_id: client_id,
209
+ client_secret: client_secret,
210
+ scope: SCOPE,
211
+ redirect_uri: redirect_uri,
212
+ authorization_uri: "https://accounts.google.com/o/oauth2/auth",
213
+ token_credential_uri: "https://oauth2.googleapis.com/token"
214
+ )
215
+
216
+ # Generate authorization URL
217
+ url = client.authorization_uri.to_s
218
+
219
+ puts "DEBUG: Client ID: #{client_id[0..20]}..."
220
+ puts "DEBUG: Scope: #{SCOPE}"
221
+ puts "DEBUG: Redirect URI: #{redirect_uri}"
222
+ puts
223
+
224
+ # Start callback server to capture OAuth code
225
+ puts "Starting temporary web server to capture OAuth callback..."
226
+ puts "Opening authorization URL in your default browser..."
227
+ puts url
228
+ puts
229
+
230
+ # Automatically open URL in default browser on macOS/Unix
231
+ if system("which open > /dev/null 2>&1")
232
+ system("open", url)
233
+ else
234
+ puts "Could not automatically open browser. Please copy the URL above manually."
235
+ end
236
+ puts
237
+ puts "Waiting for OAuth callback... (will timeout in 60 seconds)"
238
+
239
+ # Wait for the authorization code with timeout
240
+ code = GoogleAuthServer.capture_auth_code
241
+
242
+ unless code
243
+ raise "Failed to receive authorization code. Please try again."
244
+ end
245
+
246
+ puts "✅ Authorization code received!"
247
+ client.code = code
248
+ client.fetch_access_token!
249
+
250
+ # Save credentials to config
251
+ credentials_data = {
252
+ client_id: client.client_id,
253
+ client_secret: client.client_secret,
254
+ scope: client.scope,
255
+ refresh_token: client.refresh_token,
256
+ access_token: client.access_token,
257
+ expires_at: client.expires_at
258
+ }
259
+
260
+ Mcpeasy::Config.save_google_token(credentials_data)
261
+ puts "✅ Authentication successful! Token saved to config"
262
+
263
+ client
264
+ rescue => e
265
+ log_error("perform_auth_flow", e)
266
+ raise "Authentication flow failed: #{e.message}"
267
+ end
268
+
269
+ private
270
+
271
+ def authorize
272
+ credentials_data = Mcpeasy::Config.google_token
273
+ unless credentials_data
274
+ raise <<~ERROR
275
+ Google Calendar authentication required!
276
+ Run the auth command first:
277
+ mcpz gmeet auth
278
+ ERROR
279
+ end
280
+
281
+ client = Signet::OAuth2::Client.new(
282
+ client_id: credentials_data.client_id,
283
+ client_secret: credentials_data.client_secret,
284
+ scope: credentials_data.scope.respond_to?(:to_a) ? credentials_data.scope.to_a.join(" ") : credentials_data.scope.to_s,
285
+ refresh_token: credentials_data.refresh_token,
286
+ access_token: credentials_data.access_token,
287
+ token_credential_uri: "https://oauth2.googleapis.com/token"
288
+ )
289
+
290
+ # Check if token needs refresh
291
+ if credentials_data.expires_at
292
+ expires_at = if credentials_data.expires_at.is_a?(String)
293
+ Time.parse(credentials_data.expires_at)
294
+ else
295
+ Time.at(credentials_data.expires_at)
296
+ end
297
+
298
+ if Time.now >= expires_at
299
+ client.refresh!
300
+ # Update saved credentials with new access token
301
+ updated_data = {
302
+ client_id: credentials_data.client_id,
303
+ client_secret: credentials_data.client_secret,
304
+ scope: credentials_data.scope.respond_to?(:to_a) ? credentials_data.scope.to_a.join(" ") : credentials_data.scope.to_s,
305
+ refresh_token: credentials_data.refresh_token,
306
+ access_token: client.access_token,
307
+ expires_at: client.expires_at
308
+ }
309
+ Mcpeasy::Config.save_google_token(updated_data)
310
+ end
311
+ end
312
+
313
+ client
314
+ rescue JSON::ParserError
315
+ raise "Invalid token data. Please re-run: mcpz gmeet auth"
316
+ rescue => e
317
+ log_error("authorize", e)
318
+ raise "Authentication failed: #{e.message}"
319
+ end
320
+
321
+ def ensure_env!
322
+ unless Mcpeasy::Config.google_client_id && Mcpeasy::Config.google_client_secret
323
+ raise <<~ERROR
324
+ Google API credentials not configured!
325
+ Please save your Google credentials.json file using:
326
+ mcpz config set_google_credentials <path_to_credentials.json>
327
+ ERROR
328
+ end
329
+ end
330
+
331
+ def extract_meet_link(event)
332
+ # Check various places where Google Meet links might be stored
333
+
334
+ # 1. Check conference data (most reliable)
335
+ if event.conference_data&.conference_solution&.name == "Google Meet"
336
+ return event.conference_data.entry_points&.find { |ep| ep.entry_point_type == "video" }&.uri
337
+ end
338
+
339
+ # 2. Check hangout link (legacy)
340
+ return event.hangout_link if event.hangout_link
341
+
342
+ # 3. Check description for meet.google.com links
343
+ if event.description
344
+ meet_match = event.description.match(/https:\/\/meet\.google\.com\/[a-z-]+/)
345
+ return meet_match[0] if meet_match
346
+ end
347
+
348
+ # 4. Check location field
349
+ if event.location
350
+ meet_match = event.location.match(/https:\/\/meet\.google\.com\/[a-z-]+/)
351
+ return meet_match[0] if meet_match
352
+ end
353
+
354
+ nil
355
+ end
356
+
357
+ def format_event_time(event_time)
358
+ return nil unless event_time
359
+
360
+ if event_time.date
361
+ # All-day event
362
+ {date: event_time.date}
363
+ elsif event_time.date_time
364
+ # Specific time event
365
+ {date_time: event_time.date_time.iso8601}
366
+ else
367
+ nil
368
+ end
369
+ end
370
+
371
+ def format_attendees(attendees)
372
+ return [] unless attendees
373
+
374
+ attendees.map do |attendee|
375
+ attendee.email
376
+ end.compact
377
+ end
378
+
379
+ def time_until_event(event_time)
380
+ now = Time.now
381
+ event_time = event_time.is_a?(String) ? Time.parse(event_time) : event_time
382
+
383
+ diff_seconds = (event_time - now).to_i
384
+ return "started" if diff_seconds < 0
385
+
386
+ if diff_seconds < 60
387
+ "#{diff_seconds} seconds"
388
+ elsif diff_seconds < 3600
389
+ "#{diff_seconds / 60} minutes"
390
+ elsif diff_seconds < 86400
391
+ hours = diff_seconds / 3600
392
+ minutes = (diff_seconds % 3600) / 60
393
+ "#{hours}h #{minutes}m"
394
+ else
395
+ days = diff_seconds / 86400
396
+ "#{days} days"
397
+ end
398
+ end
399
+
400
+ def log_error(method, error)
401
+ Mcpeasy::Config.ensure_config_dirs
402
+ File.write(
403
+ Mcpeasy::Config.log_file_path("gmeet", "error"),
404
+ "#{Time.now}: #{method} error: #{error.class}: #{error.message}\n#{error.backtrace.join("\n")}\n",
405
+ mode: "a"
406
+ )
407
+ end
408
+ end
409
+ end
@@ -0,0 +1,287 @@
1
+ # Notion MCP Server
2
+
3
+ A Ruby-based Model Context Protocol (MCP) server that provides programmatic access to Notion's API. This server can operate in two modes:
4
+
5
+ 1. **CLI script**: Direct command-line usage for searching pages, databases, and retrieving content
6
+ 2. **MCP server**: Integration with AI assistants like Claude Code
7
+
8
+ ## Features
9
+
10
+ - 🔍 **Search pages** and databases in your Notion workspace
11
+ - 📄 **Get page details** including properties and metadata
12
+ - 📝 **Retrieve page content** as formatted text
13
+ - 🗃️ **Query databases** to find specific entries
14
+ - 👥 **List users** in your Notion workspace
15
+ - 👤 **Get user details** including email and avatar
16
+ - 🤖 **Get bot information** for the integration
17
+ - 🔐 **API key authentication** with secure credential storage
18
+ - 🛡️ **Error handling** with comprehensive API failure reporting
19
+ - ✅ **Connection testing** to verify authentication before operations
20
+
21
+ ## Prerequisites
22
+
23
+ ### 1. Ruby Environment
24
+
25
+ ```bash
26
+ # Install Ruby dependencies
27
+ bundle install
28
+ ```
29
+
30
+ Required gems:
31
+ - `standard` - Ruby code linting
32
+
33
+ ### 2. Notion Integration Setup
34
+
35
+ #### Step 1: Create a Notion Integration
36
+
37
+ 1. Go to https://www.notion.so/my-integrations
38
+ 2. Click **"New integration"**
39
+ 3. Give your integration a name (e.g., "MCPEasy Integration")
40
+ 4. Select the workspace you want to integrate with
41
+ 5. Click **"Submit"**
42
+ 6. Copy the **"Internal Integration Token"** (starts with `secret_`)
43
+
44
+ #### Step 2: Grant Database/Page Access
45
+
46
+ Your integration needs explicit access to pages and databases:
47
+
48
+ 1. **For databases**: Open the database → Click "..." → "Add connections" → Select your integration
49
+ 2. **For pages**: Open the page → Click "..." → "Add connections" → Select your integration
50
+
51
+ **Important**: The integration can only access content you explicitly share with it.
52
+
53
+ #### Step 3: Configure Credentials
54
+
55
+ Configure your Notion API key using the gem's configuration system:
56
+
57
+ ```bash
58
+ mcpz notion set_api_key secret_your-actual-notion-token
59
+ ```
60
+
61
+ This will store your API key securely in `~/.config/mcpeasy/notion.json`.
62
+
63
+ ## Usage
64
+
65
+ ### CLI Mode
66
+
67
+ #### Test Connection
68
+ ```bash
69
+ mcpz notion test
70
+ ```
71
+
72
+ #### Search Pages
73
+ ```bash
74
+ # Search all pages
75
+ mcpz notion search_pages
76
+
77
+ # Search with query
78
+ mcpz notion search_pages "meeting notes"
79
+
80
+ # Limit results
81
+ mcpz notion search_pages "project" --limit 5
82
+ ```
83
+
84
+ #### Search Databases
85
+ ```bash
86
+ # Search all databases
87
+ mcpz notion search_databases
88
+
89
+ # Search with query
90
+ mcpz notion search_databases "tasks"
91
+
92
+ # Limit results
93
+ mcpz notion search_databases "calendar" --limit 3
94
+ ```
95
+
96
+ #### Get Page Details
97
+ ```bash
98
+ # Get page metadata and properties
99
+ mcpz notion get_page PAGE_ID
100
+ ```
101
+
102
+ #### Get Page Content
103
+ ```bash
104
+ # Get the text content of a page
105
+ mcpz notion get_content PAGE_ID
106
+ ```
107
+
108
+ #### Query Database Entries
109
+ ```bash
110
+ # Get entries from a database
111
+ mcpz notion query_database DATABASE_ID
112
+
113
+ # Limit results
114
+ mcpz notion query_database DATABASE_ID --limit 20
115
+ ```
116
+
117
+ #### User Management
118
+ ```bash
119
+ # List all users in workspace
120
+ mcpz notion list_users
121
+
122
+ # Get details for a specific user
123
+ mcpz notion get_user USER_ID
124
+
125
+ # Get bot integration details
126
+ mcpz notion bot_info
127
+ ```
128
+
129
+ ### MCP Server Mode
130
+
131
+ #### Configuration for Claude Code
132
+
133
+ Add to your `.mcp.json` configuration:
134
+
135
+ ```json
136
+ {
137
+ "mcpServers": {
138
+ "notion": {
139
+ "command": "mcpz",
140
+ "args": ["notion", "mcp"]
141
+ }
142
+ }
143
+ }
144
+ ```
145
+
146
+ #### Run as Standalone MCP Server
147
+
148
+ ```bash
149
+ mcpz notion mcp
150
+ ```
151
+
152
+ The server provides these tools to Claude Code:
153
+
154
+ - **test_connection**: Test Notion API connectivity
155
+ - **search_pages**: Search for pages in your workspace
156
+ - **search_databases**: Find databases in your workspace
157
+ - **get_page**: Get detailed information about a specific page
158
+ - **get_page_content**: Retrieve the text content of a page
159
+ - **query_database**: Query entries within a specific database
160
+ - **list_users**: List all users in the workspace
161
+ - **get_user**: Get details of a specific user
162
+ - **get_bot_user**: Get information about the integration bot
163
+
164
+ ## Security & Permissions
165
+
166
+ ### Required Notion Permissions
167
+
168
+ Your integration needs to be added to each page/database you want to access:
169
+
170
+ - **Read content**: Required for all operations
171
+ - **No additional permissions needed**: This integration is read-only
172
+
173
+ ### Local File Storage
174
+
175
+ - **Credentials**: Stored in `~/.config/mcpeasy/notion.json`
176
+ - **Logs**: Application logs for debugging
177
+
178
+ ### Best Practices
179
+
180
+ 1. **Never commit** API keys to version control
181
+ 2. **Grant minimal access** - only share necessary pages/databases with the integration
182
+ 3. **Regular rotation** of API keys (recommended annually)
183
+ 4. **Monitor usage** through Notion's integration settings
184
+
185
+ ## Troubleshooting
186
+
187
+ ### Common Issues
188
+
189
+ #### "Authentication failed" Error
190
+ - Check that your API key is correct and starts with `secret_`
191
+ - Re-run: `mcpz notion set_api_key secret_your-actual-notion-token`
192
+ - Verify the integration hasn't been deleted or disabled
193
+ - Ensure you're using an "Internal Integration Token", not a public OAuth token
194
+
195
+ #### "Access forbidden" Error
196
+ - The integration doesn't have access to the requested resource
197
+ - Add the integration to the specific page or database:
198
+ - Open page/database → "..." menu → "Add connections" → Select your integration
199
+
200
+ #### "Resource not found" Error
201
+ - Verify the page/database ID is correct
202
+ - Ensure the integration has been granted access to that specific resource
203
+ - Check that the page/database hasn't been deleted
204
+
205
+ #### "Rate limit exceeded" Error
206
+ - Notion has rate limits (3 requests per second)
207
+ - Wait a moment and try again
208
+ - The tool includes automatic retry logic for rate limits
209
+
210
+ ### Finding Page and Database IDs
211
+
212
+ 1. **From Notion URL**:
213
+ - Page: `https://notion.so/Page-Title-32alphanumeric` → ID is the 32-character string
214
+ - Database: `https://notion.so/database/32alphanumeric` → ID is the 32-character string
215
+
216
+ 2. **From search results**: Use `mcpz notion search_pages` or `mcpz notion search_databases`
217
+
218
+ ### Testing the Setup
219
+
220
+ 1. **Test CLI authentication**:
221
+ ```bash
222
+ mcpz notion test
223
+ ```
224
+
225
+ 2. **Test MCP server**:
226
+ ```bash
227
+ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | mcpz notion mcp
228
+ ```
229
+
230
+ 3. **Test basic search**:
231
+ ```bash
232
+ mcpz notion search_pages
233
+ ```
234
+
235
+ ## Development
236
+
237
+ ### File Structure
238
+
239
+ ```
240
+ lib/utilities/notion/
241
+ ├── cli.rb # Thor-based CLI interface
242
+ ├── mcp.rb # MCP server implementation
243
+ ├── service.rb # Notion API wrapper
244
+ └── README.md # This file
245
+ ```
246
+
247
+ ### Adding New Features
248
+
249
+ 1. **New API methods**: Add to `Service` class
250
+ 2. **New CLI commands**: Add to `CLI` class
251
+ 3. **New MCP tools**: Add to `MCPServer` class
252
+
253
+ ### API Coverage
254
+
255
+ Currently implemented Notion API endpoints:
256
+ - `/search` - Search pages and databases
257
+ - `/pages/{id}` - Get page details
258
+ - `/blocks/{id}/children` - Get page content blocks
259
+ - `/databases/{id}/query` - Query database entries
260
+ - `/users` - List all users in workspace
261
+ - `/users/{id}` - Get specific user details
262
+ - `/users/me` - Get bot user information and test authentication
263
+
264
+ ### Testing
265
+
266
+ ```bash
267
+ # Run Ruby linting
268
+ bundle exec standardrb
269
+
270
+ # Test CLI commands
271
+ mcpz notion test
272
+ mcpz notion search_pages
273
+
274
+ # Test MCP server manually
275
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | mcpz notion mcp
276
+ ```
277
+
278
+ ## Contributing
279
+
280
+ 1. Follow existing code patterns and style
281
+ 2. Add comprehensive error handling
282
+ 3. Update this README for new features
283
+ 4. Test both CLI and MCP modes
284
+
285
+ ## License
286
+
287
+ This project follows the same license as the parent repository.