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,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/setup"
4
+ require "slack-ruby-client"
5
+ require "mcpeasy/config"
6
+
7
+ module Slack
8
+ class Service
9
+ def initialize
10
+ ensure_env!
11
+ @client = ::Slack::Web::Client.new(
12
+ token: Mcpeasy::Config.slack_bot_token,
13
+ timeout: 10, # 10 second timeout
14
+ open_timeout: 5 # 5 second connection timeout
15
+ )
16
+ end
17
+
18
+ def post_message(channel:, text:, username: nil, thread_ts: nil)
19
+ # Clean up parameters
20
+ clean_channel = channel.to_s.sub(/^#/, "").strip
21
+ clean_text = text.to_s.strip
22
+
23
+ # Validate inputs
24
+ raise "Channel cannot be empty" if clean_channel.empty?
25
+ raise "Text cannot be empty" if clean_text.empty?
26
+
27
+ # Build request parameters
28
+ params = {
29
+ channel: clean_channel,
30
+ text: clean_text
31
+ }
32
+ params[:username] = username if username && !username.to_s.strip.empty?
33
+ params[:thread_ts] = thread_ts if thread_ts && !thread_ts.to_s.strip.empty?
34
+
35
+ # Retry logic for reliability
36
+ max_retries = 3
37
+ retry_count = 0
38
+
39
+ begin
40
+ response = @client.chat_postMessage(params)
41
+
42
+ if response["ok"]
43
+ response
44
+ else
45
+ raise "Failed to post message: #{response["error"]} (#{response.inspect})"
46
+ end
47
+ rescue ::Slack::Web::Api::Errors::TooManyRequestsError => e
48
+ retry_count += 1
49
+ if retry_count <= max_retries
50
+ sleep_time = e.retry_after || 1
51
+ sleep(sleep_time)
52
+ retry
53
+ else
54
+ raise "Slack API Error: #{e.message}"
55
+ end
56
+ rescue ::Slack::Web::Api::Errors::SlackError => e
57
+ retry_count += 1
58
+ if retry_count <= max_retries && retryable_error?(e)
59
+ sleep(0.5 * retry_count) # Exponential backoff
60
+ retry
61
+ else
62
+ raise "Slack API Error: #{e.message}"
63
+ end
64
+ rescue => e
65
+ Mcpeasy::Config.ensure_config_dirs
66
+ File.write(Mcpeasy::Config.log_file_path("slack", "error"), "#{Time.now}: Slack::Service error: #{e.class}: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
67
+ raise e
68
+ end
69
+ end
70
+
71
+ def list_channels(limit: 100, cursor: nil, exclude_archived: true)
72
+ params = {
73
+ types: "public_channel,private_channel",
74
+ limit: [limit.to_i, 1000].min,
75
+ exclude_archived: exclude_archived
76
+ }
77
+ params[:cursor] = cursor if cursor && !cursor.to_s.strip.empty?
78
+
79
+ response = @client.conversations_list(params)
80
+
81
+ if response["ok"]
82
+ channels = response["channels"].to_a.map do |channel|
83
+ {
84
+ name: channel["name"],
85
+ id: channel["id"]
86
+ }
87
+ end
88
+
89
+ {
90
+ channels: channels,
91
+ has_more: response.dig("response_metadata", "next_cursor") ? true : false,
92
+ next_cursor: response.dig("response_metadata", "next_cursor")
93
+ }
94
+ else
95
+ raise "Failed to list channels: #{response["error"]}"
96
+ end
97
+ rescue ::Slack::Web::Api::Errors::SlackError => e
98
+ raise "Slack API Error: #{e.message}"
99
+ end
100
+
101
+ def test_connection
102
+ response = @client.auth_test
103
+
104
+ if response["ok"]
105
+ response
106
+ else
107
+ raise "Authentication failed: #{response["error"]}"
108
+ end
109
+ rescue ::Slack::Web::Api::Errors::SlackError => e
110
+ raise "Slack API Error: #{e.message}"
111
+ end
112
+
113
+ def tool_definitions
114
+ end
115
+
116
+ private
117
+
118
+ def retryable_error?(error)
119
+ # Network-related errors that might be temporary
120
+ error.is_a?(::Slack::Web::Api::Errors::TimeoutError) ||
121
+ error.is_a?(::Slack::Web::Api::Errors::UnavailableError) ||
122
+ (error.respond_to?(:message) && error.message.include?("timeout"))
123
+ end
124
+
125
+ def ensure_env!
126
+ unless Mcpeasy::Config.slack_bot_token
127
+ raise <<~ERROR
128
+ Slack bot token is not configured!
129
+ Please run: mcp set slack_bot_token YOUR_TOKEN
130
+ ERROR
131
+ end
132
+ end
133
+ end
134
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: mcpeasy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Joel Helbling
@@ -134,10 +134,10 @@ files:
134
134
  - ".claudeignore"
135
135
  - ".envrc"
136
136
  - ".mcp.json"
137
+ - CHANGELOG.md
137
138
  - CLAUDE.md
138
139
  - README.md
139
140
  - bin/mcpz
140
- - env.template
141
141
  - ext/setup.rb
142
142
  - lib/mcpeasy.rb
143
143
  - lib/mcpeasy/cli.rb
@@ -147,21 +147,24 @@ files:
147
147
  - lib/utilities/_google/auth_server.rb
148
148
  - lib/utilities/gcal/README.md
149
149
  - lib/utilities/gcal/cli.rb
150
- - lib/utilities/gcal/gcal_tool.rb
151
150
  - lib/utilities/gcal/mcp.rb
151
+ - lib/utilities/gcal/service.rb
152
152
  - lib/utilities/gdrive/README.md
153
153
  - lib/utilities/gdrive/cli.rb
154
- - lib/utilities/gdrive/gdrive_tool.rb
155
154
  - lib/utilities/gdrive/mcp.rb
155
+ - lib/utilities/gdrive/service.rb
156
156
  - lib/utilities/gmeet/README.md
157
157
  - lib/utilities/gmeet/cli.rb
158
- - lib/utilities/gmeet/gmeet_tool.rb
159
158
  - lib/utilities/gmeet/mcp.rb
159
+ - lib/utilities/gmeet/service.rb
160
+ - lib/utilities/notion/README.md
161
+ - lib/utilities/notion/cli.rb
162
+ - lib/utilities/notion/mcp.rb
163
+ - lib/utilities/notion/service.rb
160
164
  - lib/utilities/slack/README.md
161
165
  - lib/utilities/slack/cli.rb
162
166
  - lib/utilities/slack/mcp.rb
163
- - lib/utilities/slack/slack_tool.rb
164
- - logs/.keep
167
+ - lib/utilities/slack/service.rb
165
168
  - mcpeasy.gemspec
166
169
  homepage: https://github.com/joelhelbling/mcpeasy
167
170
  licenses:
@@ -185,7 +188,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
185
188
  - !ruby/object:Gem::Version
186
189
  version: '0'
187
190
  requirements: []
188
- rubygems_version: 3.6.7
191
+ rubygems_version: 3.6.9
189
192
  specification_version: 4
190
193
  summary: MCP servers made easy
191
194
  test_files: []
data/env.template DELETED
@@ -1,11 +0,0 @@
1
- # Copy this file to .env and replace the placeholders with your own values
2
-
3
- # Used by the slack_poster.rb script
4
- SLACK_BOT_TOKEN=xoxb-REPLACE-ME
5
-
6
- # Used by the Github MCP Server
7
- GITHUB_PERSONAL_ACCESS_TOKEN=github_pat_REPLACE_ME
8
-
9
- # Used by the Google Drive MCP Server
10
- GOOGLE_CLIENT_ID=your_client_id_here.apps.googleusercontent.com
11
- GOOGLE_CLIENT_SECRET=your_client_secret_here
@@ -1,308 +0,0 @@
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