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,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require_relative "config"
5
+
6
+ module Mcpeasy
7
+ class Setup
8
+ def self.create_config_directories
9
+ puts "Setting up mcpeasy configuration directories..."
10
+
11
+ # Use Config class to create all directories including logs
12
+ Config.ensure_config_dirs
13
+
14
+ puts "Created #{Config::CONFIG_DIR}"
15
+ puts "Created #{Config::GOOGLE_DIR}"
16
+ puts "Created #{Config::SLACK_DIR}"
17
+ puts "Created #{Config::LOGS_DIR}"
18
+
19
+ puts "mcpeasy setup complete!"
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Mcpeasy
4
+ VERSION = "0.1.0"
5
+ end
data/lib/mcpeasy.rb ADDED
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "mcpeasy/version"
4
+ require_relative "mcpeasy/config"
5
+ require_relative "mcpeasy/cli"
6
+
7
+ module Mcpeasy
8
+ class Error < StandardError; end
9
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webrick"
4
+ require "timeout"
5
+
6
+ class GoogleAuthServer
7
+ def self.capture_auth_code(port: 8080, timeout: 60)
8
+ new(port: port, timeout: timeout).capture_auth_code
9
+ end
10
+
11
+ def initialize(port: 8080, timeout: 60)
12
+ @port = port
13
+ @timeout = timeout
14
+ @auth_code = nil
15
+ @auth_received = false
16
+ end
17
+
18
+ def capture_auth_code
19
+ server, server_thread = start_callback_server
20
+ wait_for_auth_code(server, server_thread)
21
+ end
22
+
23
+ private
24
+
25
+ def start_callback_server
26
+ server = WEBrick::HTTPServer.new(
27
+ Port: @port,
28
+ Logger: WEBrick::Log.new(File::NULL),
29
+ AccessLog: [],
30
+ BindAddress: "127.0.0.1"
31
+ )
32
+
33
+ server.mount_proc("/") do |req, res|
34
+ if req.query["code"]
35
+ @auth_code = req.query["code"]
36
+ @auth_received = true
37
+ res.content_type = "text/html"
38
+ res.body = success_html
39
+ schedule_shutdown(server)
40
+ elsif req.query["error"]
41
+ @auth_received = true
42
+ res.content_type = "text/html"
43
+ res.body = error_html(req.query["error"])
44
+ schedule_shutdown(server)
45
+ else
46
+ res.content_type = "text/html"
47
+ res.body = waiting_html
48
+ end
49
+ end
50
+
51
+ server_thread = Thread.new do
52
+ server.start
53
+ rescue => e
54
+ puts "Server error: #{e.message}" unless e.message.include?("shutdown")
55
+ end
56
+
57
+ sleep 0.1
58
+ [server, server_thread]
59
+ rescue => e
60
+ raise "Failed to start callback server: #{e.message}"
61
+ end
62
+
63
+ def wait_for_auth_code(server, server_thread)
64
+ begin
65
+ Timeout.timeout(@timeout) do
66
+ until @auth_received
67
+ sleep 0.1
68
+ break unless server_thread.alive?
69
+ end
70
+ end
71
+ rescue Timeout::Error
72
+ puts "\n⏰ Timeout waiting for authorization. Please try again."
73
+ return nil
74
+ ensure
75
+ begin
76
+ server&.shutdown
77
+ server_thread&.join(2)
78
+ rescue
79
+ # Ignore shutdown errors
80
+ end
81
+ end
82
+
83
+ @auth_code
84
+ end
85
+
86
+ def schedule_shutdown(server)
87
+ Thread.new do
88
+ sleep 0.5
89
+ server.shutdown
90
+ end
91
+ end
92
+
93
+ def success_html
94
+ <<~HTML
95
+ <!DOCTYPE html>
96
+ <html>
97
+ <head>
98
+ <title>Authorization Successful</title>
99
+ <style>
100
+ body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
101
+ .success { color: #28a745; }
102
+ </style>
103
+ </head>
104
+ <body>
105
+ <h1 class="success">&#x2713; Authorization Successful!</h1>
106
+ <p>You can now close this window and return to your terminal.</p>
107
+ </body>
108
+ </html>
109
+ HTML
110
+ end
111
+
112
+ def error_html(error)
113
+ <<~HTML
114
+ <!DOCTYPE html>
115
+ <html>
116
+ <head>
117
+ <title>Authorization Failed</title>
118
+ <style>
119
+ body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
120
+ .error { color: #dc3545; }
121
+ </style>
122
+ </head>
123
+ <body>
124
+ <h1 class="error">&#x2717; Authorization Failed</h1>
125
+ <p>Error: #{error}</p>
126
+ <p>Please try again from your terminal.</p>
127
+ </body>
128
+ </html>
129
+ HTML
130
+ end
131
+
132
+ def waiting_html
133
+ <<~HTML
134
+ <!DOCTYPE html>
135
+ <html>
136
+ <head>
137
+ <title>Waiting for Authorization</title>
138
+ <style>
139
+ body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
140
+ </style>
141
+ </head>
142
+ <body>
143
+ <h1>Waiting for Authorization...</h1>
144
+ <p>Please complete the authorization process.</p>
145
+ </body>
146
+ </html>
147
+ HTML
148
+ end
149
+ end
@@ -0,0 +1,237 @@
1
+ # Google Calendar MCP Server
2
+
3
+ A Ruby-based Model Context Protocol (MCP) server that provides programmatic access to Google Calendar. This server can operate in two modes:
4
+
5
+ 1. **CLI script**: Direct command-line usage for listing events and calendar information
6
+ 2. **MCP server**: Integration with AI assistants like Claude Code
7
+
8
+ ## Features
9
+
10
+ - 📅 **List events** from your Google Calendar with date range filtering
11
+ - 📋 **List calendars** and view calendar metadata
12
+ - 🔍 **Search events** by text content
13
+ - 🔐 **OAuth 2.0 authentication** with credential persistence (shares credentials with gdrive utility)
14
+ - 🛡️ **Error handling** with retry logic and comprehensive logging
15
+
16
+ ## Prerequisites
17
+
18
+ ### 1. Ruby Environment
19
+
20
+ ```bash
21
+ # Install Ruby dependencies
22
+ bundle install
23
+ ```
24
+
25
+ Required gems:
26
+ - `google-apis-calendar_v3` - Google Calendar API client
27
+ - `googleauth` - Google OAuth authentication
28
+ - `thor` - CLI framework
29
+
30
+ ### 2. Google Cloud Platform Setup
31
+
32
+ #### Step 1: Create a Google Cloud Project
33
+
34
+ 1. Go to the [Google Cloud Console](https://console.cloud.google.com/)
35
+ 2. Create a new project or select an existing one
36
+ 3. Note your project ID
37
+
38
+ #### Step 2: Enable the Google Calendar API
39
+
40
+ 1. In the Cloud Console, go to **APIs & Services > Library**
41
+ 2. Search for "Google Calendar API"
42
+ 3. Click on it and click **Enable**
43
+
44
+ #### Step 3: Create OAuth 2.0 Credentials
45
+
46
+ 1. Go to **APIs & Services > Credentials**
47
+ 2. Click **+ Create Credentials > OAuth client ID**
48
+ 3. If prompted, configure the OAuth consent screen:
49
+ - Choose **External** user type
50
+ - Fill in required fields (app name, user support email)
51
+ - Add your email to test users
52
+ - **Scopes**: Add `https://www.googleapis.com/auth/calendar.readonly`
53
+ 4. For Application type, choose **Desktop application**
54
+ 5. Give it a name (e.g., "Google Calendar MCP Server")
55
+ 6. **Add Authorized redirect URI**: `http://localhost:8080`
56
+ 7. Click **Create**
57
+ 8. Download the JSON file containing your client ID and secret
58
+
59
+ #### Step 4: Configure OAuth Credentials
60
+
61
+ The Google OAuth credentials will be configured during the authentication process. You'll need the Client ID and Client Secret from the JSON file you downloaded in Step 3.
62
+
63
+ **Note**: All Google services (Calendar, Drive, Meet) share the same authentication system, so you only need to authenticate once.
64
+
65
+ ## Authentication
66
+
67
+ Before using the Google Calendar MCP server, you need to authenticate with Google:
68
+
69
+ ```bash
70
+ mcpz google auth
71
+ ```
72
+
73
+ This will open a browser for Google OAuth authorization and save credentials to `~/.config/mcpeasy/google/token.json`. The credentials are shared with all Google services and will be automatically refreshed when needed.
74
+
75
+ ## Usage
76
+
77
+ ### CLI Mode
78
+
79
+ #### Test Connection
80
+ ```bash
81
+ mcpz gcal test
82
+ ```
83
+
84
+ #### List Events
85
+ ```bash
86
+ # List today's events
87
+ mcpz gcal events
88
+
89
+ # List events for a specific date range
90
+ mcpz gcal events --start "2024-01-01" --end "2024-01-31"
91
+
92
+ # Limit results
93
+ mcpz gcal events --max-results 10
94
+ ```
95
+
96
+ #### List Calendars
97
+ ```bash
98
+ mcpz gcal calendars
99
+ ```
100
+
101
+ ### MCP Server Mode
102
+
103
+ #### Configuration for Claude Code
104
+
105
+ Add to your `.mcp.json` configuration:
106
+
107
+ ```json
108
+ {
109
+ "mcpServers": {
110
+ "gcal": {
111
+ "command": "mcpz",
112
+ "args": ["gcal", "mcp"]
113
+ }
114
+ }
115
+ }
116
+ ```
117
+
118
+ #### Run as Standalone MCP Server
119
+
120
+ ```bash
121
+ mcpz gcal mcp
122
+ ```
123
+
124
+ The server provides these tools to Claude Code:
125
+
126
+ - **test_connection**: Test Google Calendar API connectivity
127
+ - **list_events**: List calendar events with optional date filtering
128
+ - **list_calendars**: List available calendars
129
+ - **search_events**: Search for events by text content
130
+
131
+ ## Security & Permissions
132
+
133
+ ### Required OAuth Scopes
134
+
135
+ - `https://www.googleapis.com/auth/calendar.readonly` - Read-only access to Google Calendar
136
+
137
+ ### Local File Storage
138
+
139
+ - **Credentials**: Stored in `~/.config/mcpeasy/google/token.json`
140
+ - **Logs**: Stored in `./logs/mcp_gcal_*.log`
141
+
142
+ ### Best Practices
143
+
144
+ 1. **Never commit** credential files to version control
145
+ 2. **Limit scope** to read-only access
146
+ 3. **Regular rotation** of OAuth credentials (recommended annually)
147
+ 4. **Monitor usage** through Google Cloud Console
148
+
149
+ ## Troubleshooting
150
+
151
+ ### Common Issues
152
+
153
+ #### "Authentication required" Error
154
+ - Run `mcpz google auth` to authenticate
155
+ - Check that `~/.config/mcpeasy/google/token.json` exists and is valid
156
+ - Verify your Google OAuth credentials are configured correctly
157
+
158
+ #### "Bad credentials" Error
159
+ - Regenerate OAuth credentials in Google Cloud Console
160
+ - Re-run authentication: `mcpz google auth`
161
+
162
+ #### "Calendar API has not been used" Error
163
+ - Ensure the Google Calendar API is enabled in your project
164
+ - Go to Google Cloud Console > APIs & Services > Library
165
+ - Search for "Google Calendar API" and enable it
166
+
167
+ #### "Access denied" Error
168
+ - Ensure the Google Calendar API is enabled in your project
169
+ - Check that your OAuth consent screen includes the calendar.readonly scope
170
+ - Verify you're using the correct Google account
171
+
172
+ ### Debug Logging
173
+
174
+ Check the log files for detailed error information:
175
+
176
+ ```bash
177
+ # View recent errors
178
+ tail -f logs/mcp_gcal_error.log
179
+
180
+ # View startup logs
181
+ tail -f logs/mcp_gcal_startup.log
182
+ ```
183
+
184
+ ### Testing the Setup
185
+
186
+ 1. **Test CLI authentication**:
187
+ ```bash
188
+ mcpz gcal test
189
+ ```
190
+
191
+ 2. **Test MCP server**:
192
+ ```bash
193
+ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | mcpz gcal mcp
194
+ ```
195
+
196
+ ## Development
197
+
198
+ ### File Structure
199
+
200
+ ```
201
+ utilities/gcal/
202
+ ├── cli.rb # Thor-based CLI interface
203
+ ├── mcp.rb # MCP server implementation
204
+ ├── gcal_tool.rb # Google Calendar API wrapper
205
+ └── README.md # This file
206
+ ```
207
+
208
+ ### Adding New Features
209
+
210
+ 1. **New API methods**: Add to `GcalTool` class
211
+ 2. **New CLI commands**: Add to `GcalCLI` class
212
+ 3. **New MCP tools**: Add to `MCPServer` class
213
+
214
+ ### Testing
215
+
216
+ ```bash
217
+ # Run Ruby linting
218
+ bundle exec standardrb
219
+
220
+ # Test CLI commands
221
+ mcpz gcal test
222
+ mcpz gcal events --max-results 5
223
+
224
+ # Test MCP server manually
225
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | mcpz gcal mcp
226
+ ```
227
+
228
+ ## Contributing
229
+
230
+ 1. Follow existing code patterns and style
231
+ 2. Add comprehensive error handling
232
+ 3. Update this README for new features
233
+ 4. Test both CLI and MCP modes
234
+
235
+ ## License
236
+
237
+ This project follows the same license as the parent repository.
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "thor"
5
+ require_relative "gcal_tool"
6
+
7
+ class GcalCLI < Thor
8
+ desc "test", "Test the Google Calendar API connection"
9
+ def test
10
+ response = tool.test_connection
11
+
12
+ if response[:ok]
13
+ puts "✅ Successfully connected to Google Calendar"
14
+ puts " User: #{response[:user]} (#{response[:email]})"
15
+ else
16
+ warn "❌ Connection test failed"
17
+ end
18
+ rescue RuntimeError => e
19
+ puts "❌ Failed to connect to Google Calendar: #{e.message}\n\n#{e.backtrace.join("\n")}"
20
+ exit 1
21
+ end
22
+
23
+ desc "events", "List calendar events"
24
+ method_option :start_date, type: :string, aliases: "-s", desc: "Start date (YYYY-MM-DD)"
25
+ method_option :end_date, type: :string, aliases: "-e", desc: "End date (YYYY-MM-DD)"
26
+ method_option :max_results, type: :numeric, default: 20, aliases: "-n", desc: "Max number of events"
27
+ method_option :calendar_id, type: :string, aliases: "-c", desc: "Calendar ID (default: primary)"
28
+ def events
29
+ result = tool.list_events(
30
+ start_date: options[:start_date],
31
+ end_date: options[:end_date],
32
+ max_results: options[:max_results],
33
+ calendar_id: options[:calendar_id] || "primary"
34
+ )
35
+ events = result[:events]
36
+
37
+ if events.empty?
38
+ puts "📅 No events found for the specified date range"
39
+ else
40
+ puts "📅 Found #{result[:count]} event(s):"
41
+ events.each_with_index do |event, index|
42
+ puts " #{index + 1}. #{event[:summary] || "No title"}"
43
+ puts " Start: #{format_datetime(event[:start])}"
44
+ puts " End: #{format_datetime(event[:end])}"
45
+ puts " Description: #{event[:description]}" if event[:description]
46
+ puts " Location: #{event[:location]}" if event[:location]
47
+ puts " Attendees: #{event[:attendees].join(", ")}" if event[:attendees]&.any?
48
+ puts " Link: #{event[:html_link]}"
49
+ puts
50
+ end
51
+ end
52
+ rescue RuntimeError => e
53
+ warn "❌ Failed to list events: #{e.message}"
54
+ exit 1
55
+ end
56
+
57
+ desc "calendars", "List available calendars"
58
+ def calendars
59
+ result = tool.list_calendars
60
+ calendars = result[:calendars]
61
+
62
+ if calendars.empty?
63
+ puts "📋 No calendars found"
64
+ else
65
+ puts "📋 Found #{result[:count]} calendar(s):"
66
+ calendars.each_with_index do |calendar, index|
67
+ puts " #{index + 1}. #{calendar[:summary]}"
68
+ puts " ID: #{calendar[:id]}"
69
+ puts " Description: #{calendar[:description]}" if calendar[:description]
70
+ puts " Time Zone: #{calendar[:time_zone]}"
71
+ puts " Access Role: #{calendar[:access_role]}"
72
+ puts " Primary: Yes" if calendar[:primary]
73
+ puts
74
+ end
75
+ end
76
+ rescue RuntimeError => e
77
+ warn "❌ Failed to list calendars: #{e.message}"
78
+ exit 1
79
+ end
80
+
81
+ desc "search QUERY", "Search for events by text content"
82
+ method_option :start_date, type: :string, aliases: "-s", desc: "Start date (YYYY-MM-DD)"
83
+ method_option :end_date, type: :string, aliases: "-e", desc: "End date (YYYY-MM-DD)"
84
+ method_option :max_results, type: :numeric, default: 10, aliases: "-n", desc: "Max number of events"
85
+ def search(query)
86
+ result = tool.search_events(
87
+ query,
88
+ start_date: options[:start_date],
89
+ end_date: options[:end_date],
90
+ max_results: options[:max_results]
91
+ )
92
+ events = result[:events]
93
+
94
+ if events.empty?
95
+ puts "🔍 No events found matching '#{query}'"
96
+ else
97
+ puts "🔍 Found #{result[:count]} event(s) matching '#{query}':"
98
+ events.each_with_index do |event, index|
99
+ puts " #{index + 1}. #{event[:summary] || "No title"}"
100
+ puts " Start: #{format_datetime(event[:start])}"
101
+ puts " End: #{format_datetime(event[:end])}"
102
+ puts " Description: #{event[:description]}" if event[:description]
103
+ puts " Location: #{event[:location]}" if event[:location]
104
+ puts " Calendar: #{event[:calendar_id]}"
105
+ puts " Link: #{event[:html_link]}"
106
+ puts
107
+ end
108
+ end
109
+ rescue RuntimeError => e
110
+ warn "❌ Failed to search events: #{e.message}"
111
+ exit 1
112
+ end
113
+
114
+ private
115
+
116
+ def tool
117
+ @tool ||= GcalTool.new
118
+ end
119
+
120
+ def format_datetime(datetime_info)
121
+ return "Unknown" unless datetime_info
122
+
123
+ if datetime_info[:date]
124
+ # All-day event
125
+ datetime_info[:date]
126
+ elsif datetime_info[:date_time]
127
+ # Specific time event
128
+ time = Time.parse(datetime_info[:date_time])
129
+ time.strftime("%Y-%m-%d %H:%M")
130
+ else
131
+ "Unknown"
132
+ end
133
+ end
134
+ end