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,438 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "json"
6
+ require_relative "gmeet_tool"
7
+
8
+ class MCPServer
9
+ def initialize
10
+ # Defer GmeetTool initialization until actually needed
11
+ @gmeet_tool = nil
12
+ @tools = {
13
+ "test_connection" => {
14
+ name: "test_connection",
15
+ description: "Test the Google Calendar API connection",
16
+ inputSchema: {
17
+ type: "object",
18
+ properties: {},
19
+ required: []
20
+ }
21
+ },
22
+ "list_meetings" => {
23
+ name: "list_meetings",
24
+ description: "List Google Meet meetings with optional date filtering",
25
+ inputSchema: {
26
+ type: "object",
27
+ properties: {
28
+ start_date: {
29
+ type: "string",
30
+ description: "Start date in YYYY-MM-DD format (default: today)"
31
+ },
32
+ end_date: {
33
+ type: "string",
34
+ description: "End date in YYYY-MM-DD format (default: 7 days from start)"
35
+ },
36
+ max_results: {
37
+ type: "number",
38
+ description: "Maximum number of meetings to return (default: 20)"
39
+ },
40
+ calendar_id: {
41
+ type: "string",
42
+ description: "Calendar ID to list meetings from (default: primary calendar)"
43
+ }
44
+ },
45
+ required: []
46
+ }
47
+ },
48
+ "upcoming_meetings" => {
49
+ name: "upcoming_meetings",
50
+ description: "List upcoming Google Meet meetings in the next 24 hours",
51
+ inputSchema: {
52
+ type: "object",
53
+ properties: {
54
+ max_results: {
55
+ type: "number",
56
+ description: "Maximum number of meetings to return (default: 10)"
57
+ },
58
+ calendar_id: {
59
+ type: "string",
60
+ description: "Calendar ID to list meetings from (default: primary calendar)"
61
+ }
62
+ },
63
+ required: []
64
+ }
65
+ },
66
+ "search_meetings" => {
67
+ name: "search_meetings",
68
+ description: "Search for Google Meet meetings by text content",
69
+ inputSchema: {
70
+ type: "object",
71
+ properties: {
72
+ query: {
73
+ type: "string",
74
+ description: "Search query to find meetings"
75
+ },
76
+ start_date: {
77
+ type: "string",
78
+ description: "Start date in YYYY-MM-DD format (default: today)"
79
+ },
80
+ end_date: {
81
+ type: "string",
82
+ description: "End date in YYYY-MM-DD format (default: 30 days from start)"
83
+ },
84
+ max_results: {
85
+ type: "number",
86
+ description: "Maximum number of meetings to return (default: 10)"
87
+ }
88
+ },
89
+ required: ["query"]
90
+ }
91
+ },
92
+ "get_meeting_url" => {
93
+ name: "get_meeting_url",
94
+ description: "Get the Google Meet URL for a specific event",
95
+ inputSchema: {
96
+ type: "object",
97
+ properties: {
98
+ event_id: {
99
+ type: "string",
100
+ description: "Calendar event ID"
101
+ },
102
+ calendar_id: {
103
+ type: "string",
104
+ description: "Calendar ID (default: primary calendar)"
105
+ }
106
+ },
107
+ required: ["event_id"]
108
+ }
109
+ }
110
+ }
111
+ end
112
+
113
+ def run
114
+ # Disable stdout buffering for immediate response
115
+ $stdout.sync = true
116
+
117
+ # Log startup to file instead of stdout to avoid protocol interference
118
+ Mcpeasy::Config.ensure_config_dirs
119
+ File.write(Mcpeasy::Config.log_file_path("gmeet", "startup"), "#{Time.now}: Google Meet MCP Server starting on stdio\n", mode: "a")
120
+ while (line = $stdin.gets)
121
+ handle_request(line.strip)
122
+ end
123
+ rescue Interrupt
124
+ # Silent shutdown
125
+ rescue => e
126
+ # Log to a file instead of stderr to avoid protocol interference
127
+ File.write(Mcpeasy::Config.log_file_path("gmeet", "error"), "#{Time.now}: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
128
+ end
129
+
130
+ private
131
+
132
+ def handle_request(line)
133
+ return if line.empty?
134
+
135
+ begin
136
+ request = JSON.parse(line)
137
+ response = process_request(request)
138
+ if response
139
+ puts JSON.generate(response)
140
+ $stdout.flush
141
+ end
142
+ rescue JSON::ParserError => e
143
+ error_response = {
144
+ jsonrpc: "2.0",
145
+ id: nil,
146
+ error: {
147
+ code: -32700,
148
+ message: "Parse error",
149
+ data: e.message
150
+ }
151
+ }
152
+ puts JSON.generate(error_response)
153
+ $stdout.flush
154
+ rescue => e
155
+ File.write(Mcpeasy::Config.log_file_path("gmeet", "error"), "#{Time.now}: Error handling request: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
156
+ error_response = {
157
+ jsonrpc: "2.0",
158
+ id: request&.dig("id"),
159
+ error: {
160
+ code: -32603,
161
+ message: "Internal error",
162
+ data: e.message
163
+ }
164
+ }
165
+ puts JSON.generate(error_response)
166
+ $stdout.flush
167
+ end
168
+ end
169
+
170
+ def process_request(request)
171
+ id = request["id"]
172
+ method = request["method"]
173
+ params = request["params"] || {}
174
+
175
+ case method
176
+ when "notifications/initialized"
177
+ # Client acknowledgment - no response needed
178
+ nil
179
+ when "initialize"
180
+ initialize_response(id, params)
181
+ when "tools/list"
182
+ tools_list_response(id, params)
183
+ when "tools/call"
184
+ tools_call_response(id, params)
185
+ else
186
+ {
187
+ jsonrpc: "2.0",
188
+ id: id,
189
+ error: {
190
+ code: -32601,
191
+ message: "Method not found",
192
+ data: "Unknown method: #{method}"
193
+ }
194
+ }
195
+ end
196
+ end
197
+
198
+ def initialize_response(id, params)
199
+ {
200
+ jsonrpc: "2.0",
201
+ id: id,
202
+ result: {
203
+ protocolVersion: "2024-11-05",
204
+ capabilities: {
205
+ tools: {}
206
+ },
207
+ serverInfo: {
208
+ name: "gmeet-mcp-server",
209
+ version: "1.0.0"
210
+ }
211
+ }
212
+ }
213
+ end
214
+
215
+ def tools_list_response(id, params)
216
+ {
217
+ jsonrpc: "2.0",
218
+ id: id,
219
+ result: {
220
+ tools: @tools.values
221
+ }
222
+ }
223
+ end
224
+
225
+ def tools_call_response(id, params)
226
+ tool_name = params["name"]
227
+ arguments = params["arguments"] || {}
228
+
229
+ unless @tools.key?(tool_name)
230
+ return {
231
+ jsonrpc: "2.0",
232
+ id: id,
233
+ error: {
234
+ code: -32602,
235
+ message: "Unknown tool",
236
+ data: "Tool '#{tool_name}' not found"
237
+ }
238
+ }
239
+ end
240
+
241
+ begin
242
+ result = call_tool(tool_name, arguments)
243
+ {
244
+ jsonrpc: "2.0",
245
+ id: id,
246
+ result: {
247
+ content: [
248
+ {
249
+ type: "text",
250
+ text: result
251
+ }
252
+ ],
253
+ isError: false
254
+ }
255
+ }
256
+ rescue => e
257
+ File.write(Mcpeasy::Config.log_file_path("gmeet", "error"), "#{Time.now}: Tool error: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
258
+ {
259
+ jsonrpc: "2.0",
260
+ id: id,
261
+ result: {
262
+ content: [
263
+ {
264
+ type: "text",
265
+ text: "❌ Error: #{e.message}"
266
+ }
267
+ ],
268
+ isError: true
269
+ }
270
+ }
271
+ end
272
+ end
273
+
274
+ def call_tool(tool_name, arguments)
275
+ # Initialize GmeetTool only when needed
276
+ @gmeet_tool ||= GmeetTool.new
277
+
278
+ case tool_name
279
+ when "test_connection"
280
+ test_connection
281
+ when "list_meetings"
282
+ list_meetings(arguments)
283
+ when "upcoming_meetings"
284
+ upcoming_meetings(arguments)
285
+ when "search_meetings"
286
+ search_meetings(arguments)
287
+ when "get_meeting_url"
288
+ get_meeting_url(arguments)
289
+ else
290
+ raise "Unknown tool: #{tool_name}"
291
+ end
292
+ end
293
+
294
+ def test_connection
295
+ response = @gmeet_tool.test_connection
296
+ if response[:ok]
297
+ "✅ Successfully connected to Google Calendar.\n" \
298
+ " User: #{response[:user]} (#{response[:email]})"
299
+ else
300
+ raise "Connection test failed"
301
+ end
302
+ end
303
+
304
+ def list_meetings(arguments)
305
+ start_date = arguments["start_date"]
306
+ end_date = arguments["end_date"]
307
+ max_results = arguments["max_results"]&.to_i || 20
308
+ calendar_id = arguments["calendar_id"] || "primary"
309
+
310
+ result = @gmeet_tool.list_meetings(
311
+ start_date: start_date,
312
+ end_date: end_date,
313
+ max_results: max_results,
314
+ calendar_id: calendar_id
315
+ )
316
+ meetings = result[:meetings]
317
+
318
+ if meetings.empty?
319
+ "🎥 No Google Meet meetings found for the specified date range"
320
+ else
321
+ output = "🎥 Found #{result[:count]} Google Meet meeting(s):\n\n"
322
+ meetings.each_with_index do |meeting, index|
323
+ output << "#{index + 1}. **#{meeting[:summary] || "No title"}**\n"
324
+ output << " - Start: #{format_datetime(meeting[:start])}\n"
325
+ output << " - End: #{format_datetime(meeting[:end])}\n"
326
+ output << " - Description: #{meeting[:description] || "No description"}\n" if meeting[:description]
327
+ output << " - Location: #{meeting[:location]}\n" if meeting[:location]
328
+ output << " - Attendees: #{meeting[:attendees].join(", ")}\n" if meeting[:attendees]&.any?
329
+ output << " - **Meet Link: #{meeting[:meet_link]}**\n"
330
+ output << " - Calendar Link: #{meeting[:html_link]}\n\n"
331
+ end
332
+ output
333
+ end
334
+ end
335
+
336
+ def upcoming_meetings(arguments)
337
+ max_results = arguments["max_results"]&.to_i || 10
338
+ calendar_id = arguments["calendar_id"] || "primary"
339
+
340
+ result = @gmeet_tool.upcoming_meetings(
341
+ max_results: max_results,
342
+ calendar_id: calendar_id
343
+ )
344
+ meetings = result[:meetings]
345
+
346
+ if meetings.empty?
347
+ "🎥 No upcoming Google Meet meetings found in the next 24 hours"
348
+ else
349
+ output = "🎥 Found #{result[:count]} upcoming Google Meet meeting(s):\n\n"
350
+ meetings.each_with_index do |meeting, index|
351
+ output << "#{index + 1}. **#{meeting[:summary] || "No title"}**\n"
352
+ output << " - Start: #{format_datetime(meeting[:start])} (#{meeting[:time_until_start]})\n"
353
+ output << " - End: #{format_datetime(meeting[:end])}\n"
354
+ output << " - Description: #{meeting[:description] || "No description"}\n" if meeting[:description]
355
+ output << " - Location: #{meeting[:location]}\n" if meeting[:location]
356
+ output << " - Attendees: #{meeting[:attendees].join(", ")}\n" if meeting[:attendees]&.any?
357
+ output << " - **Meet Link: #{meeting[:meet_link]}**\n"
358
+ output << " - Calendar Link: #{meeting[:html_link]}\n\n"
359
+ end
360
+ output
361
+ end
362
+ end
363
+
364
+ def search_meetings(arguments)
365
+ unless arguments["query"]
366
+ raise "Missing required argument: query"
367
+ end
368
+
369
+ query = arguments["query"].to_s
370
+ start_date = arguments["start_date"]
371
+ end_date = arguments["end_date"]
372
+ max_results = arguments["max_results"]&.to_i || 10
373
+
374
+ result = @gmeet_tool.search_meetings(
375
+ query,
376
+ start_date: start_date,
377
+ end_date: end_date,
378
+ max_results: max_results
379
+ )
380
+ meetings = result[:meetings]
381
+
382
+ if meetings.empty?
383
+ "🔍 No Google Meet meetings found matching '#{query}'"
384
+ else
385
+ output = "🔍 Found #{result[:count]} Google Meet meeting(s) matching '#{query}':\n\n"
386
+ meetings.each_with_index do |meeting, index|
387
+ output << "#{index + 1}. **#{meeting[:summary] || "No title"}**\n"
388
+ output << " - Start: #{format_datetime(meeting[:start])}\n"
389
+ output << " - End: #{format_datetime(meeting[:end])}\n"
390
+ output << " - Description: #{meeting[:description] || "No description"}\n" if meeting[:description]
391
+ output << " - Location: #{meeting[:location]}\n" if meeting[:location]
392
+ output << " - **Meet Link: #{meeting[:meet_link]}**\n"
393
+ output << " - Calendar Link: #{meeting[:html_link]}\n\n"
394
+ end
395
+ output
396
+ end
397
+ end
398
+
399
+ def get_meeting_url(arguments)
400
+ unless arguments["event_id"]
401
+ raise "Missing required argument: event_id"
402
+ end
403
+
404
+ event_id = arguments["event_id"].to_s
405
+ calendar_id = arguments["calendar_id"] || "primary"
406
+
407
+ result = @gmeet_tool.get_meeting_url(event_id, calendar_id: calendar_id)
408
+
409
+ output = "🎥 **#{result[:summary] || "Meeting"}**\n"
410
+ output << " - Start: #{format_datetime(result[:start])}\n"
411
+ output << " - End: #{format_datetime(result[:end])}\n"
412
+ output << " - **Meet Link: #{result[:meet_link]}**\n"
413
+ output << " - Event ID: #{result[:event_id]}\n"
414
+
415
+ output
416
+ end
417
+
418
+ private
419
+
420
+ def format_datetime(datetime_info)
421
+ return "Unknown" unless datetime_info
422
+
423
+ if datetime_info[:date]
424
+ # All-day event
425
+ datetime_info[:date]
426
+ elsif datetime_info[:date_time]
427
+ # Specific time event
428
+ time = Time.parse(datetime_info[:date_time])
429
+ time.strftime("%Y-%m-%d %H:%M")
430
+ else
431
+ "Unknown"
432
+ end
433
+ end
434
+ end
435
+
436
+ if __FILE__ == $0
437
+ MCPServer.new.run
438
+ end
@@ -0,0 +1,211 @@
1
+ # Slack MCP Server
2
+
3
+ A Ruby-based Model Context Protocol (MCP) server that provides programmatic access to Slack's Web API. This server can operate in two modes:
4
+
5
+ 1. **CLI script**: Direct command-line usage for posting messages to channels
6
+ 2. **MCP server**: Integration with AI assistants like Claude Code
7
+
8
+ ## Features
9
+
10
+ - 💬 **Post messages** to Slack channels with optional custom usernames
11
+ - 📋 **List channels** to see available public and private channels
12
+ - 🔐 **Bot token authentication** with secure credential storage
13
+ - 🛡️ **Error handling** with comprehensive API failure reporting
14
+ - ✅ **Connection testing** to verify authentication before operations
15
+
16
+ ## Prerequisites
17
+
18
+ ### 1. Ruby Environment
19
+
20
+ ```bash
21
+ # Install Ruby dependencies
22
+ bundle install
23
+ ```
24
+
25
+ Required gems:
26
+ - `slack-ruby-client` - Slack Web API client
27
+ - `standard` - Ruby code linting
28
+
29
+ ### 2. Slack App Setup
30
+
31
+ #### Step 1: Create a Slack App
32
+
33
+ 1. Go to https://api.slack.com/apps
34
+ 2. Click **"Create New App"** → **"From scratch"**
35
+ 3. Give your app a name and select your workspace
36
+ 4. Note your app's details
37
+
38
+ #### Step 2: Configure OAuth Permissions
39
+
40
+ 1. Go to **"OAuth & Permissions"** in the sidebar
41
+ 2. Under **"Scopes"** → **"Bot Token Scopes"**, add these permissions:
42
+ - `chat:write` - Required to post messages
43
+ - `channels:read` - Optional, for listing public channels
44
+ - `groups:read` - Optional, for listing private channels
45
+ 3. Click **"Install to Workspace"** at the top
46
+ 4. Copy the **"Bot User OAuth Token"** (starts with `xoxb-`)
47
+
48
+ #### Step 3: Configure Credentials
49
+
50
+ Configure your Slack bot token using the gem's configuration system:
51
+
52
+ ```bash
53
+ mcpz slack set_bot_token xoxb-your-actual-slack-token
54
+ ```
55
+
56
+ This will store your bot token securely in `~/.config/mcpeasy/slack.json`.
57
+
58
+ ## Usage
59
+
60
+ ### CLI Mode
61
+
62
+ #### Test Connection
63
+ ```bash
64
+ mcpz slack test
65
+ ```
66
+
67
+ #### Post Messages
68
+ ```bash
69
+ # Post a simple message
70
+ mcpz slack post general "Hello from Ruby!"
71
+
72
+ # Post with a custom username
73
+ mcpz slack post general "Deployment completed successfully!" --username "DeployBot"
74
+
75
+ # Use channel with or without # prefix
76
+ mcpz slack post "#general" "Message with # prefix"
77
+ ```
78
+
79
+ #### List Channels
80
+ ```bash
81
+ # List all available channels
82
+ mcpz slack channels
83
+ ```
84
+
85
+ ### MCP Server Mode
86
+
87
+ #### Configuration for Claude Code
88
+
89
+ Add to your `.mcp.json` configuration:
90
+
91
+ ```json
92
+ {
93
+ "mcpServers": {
94
+ "slack": {
95
+ "command": "mcpz",
96
+ "args": ["slack", "mcp"]
97
+ }
98
+ }
99
+ }
100
+ ```
101
+
102
+ #### Run as Standalone MCP Server
103
+
104
+ ```bash
105
+ mcpz slack mcp
106
+ ```
107
+
108
+ The server provides these tools to Claude Code:
109
+
110
+ - **test_connection**: Test Slack API connectivity
111
+ - **post_message**: Post messages to channels with optional custom username
112
+ - **list_channels**: List available public and private channels
113
+
114
+ ## Security & Permissions
115
+
116
+ ### Required OAuth Scopes
117
+
118
+ - `chat:write` - Required to post messages
119
+ - `channels:read` - Optional, for listing public channels
120
+ - `groups:read` - Optional, for listing private channels
121
+
122
+ ### Local File Storage
123
+
124
+ - **Credentials**: Stored in `~/.config/mcpeasy/slack.json`
125
+ - **Logs**: Application logs for debugging
126
+
127
+ ### Best Practices
128
+
129
+ 1. **Never commit** bot tokens to version control
130
+ 2. **Limit permissions** to only what's needed for your use case
131
+ 3. **Regular rotation** of bot tokens (recommended annually)
132
+ 4. **Monitor usage** through Slack's app management dashboard
133
+
134
+ ## Troubleshooting
135
+
136
+ ### Common Issues
137
+
138
+ #### "Invalid auth" Error
139
+ - Check that your bot token is correct and starts with `xoxb-`
140
+ - Re-run: `mcpz slack set_bot_token xoxb-your-actual-slack-token`
141
+ - Verify the token hasn't expired or been revoked
142
+ - Ensure the app is installed in your workspace
143
+
144
+ #### "Missing scope" Error
145
+ - Add the required OAuth scopes in your Slack app configuration
146
+ - Reinstall the app to workspace after adding scopes
147
+ - Required scopes: `chat:write` (minimum), `channels:read`, `groups:read` (optional)
148
+
149
+ #### "Channel not found" Error
150
+ - Verify the channel name is spelled correctly
151
+ - Ensure your bot has access to the channel (invite the bot if needed)
152
+ - Try using the channel ID instead of the name
153
+
154
+ #### "Bot not in channel" Error
155
+ - Invite your bot to the channel: `/invite @your-bot-name`
156
+ - Or use channels where the bot is already a member
157
+
158
+ ### Testing the Setup
159
+
160
+ 1. **Test CLI authentication**:
161
+ ```bash
162
+ mcpz slack test
163
+ ```
164
+
165
+ 2. **Test MCP server**:
166
+ ```bash
167
+ echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | mcpz slack mcp
168
+ ```
169
+
170
+ ## Development
171
+
172
+ ### File Structure
173
+
174
+ ```
175
+ lib/utilities/slack/
176
+ ├── cli.rb # Thor-based CLI interface
177
+ ├── mcp.rb # MCP server implementation
178
+ ├── slack_tool.rb # Slack Web API wrapper
179
+ └── README.md # This file
180
+ ```
181
+
182
+ ### Adding New Features
183
+
184
+ 1. **New API methods**: Add to `SlackTool` class
185
+ 2. **New CLI commands**: Add to `SlackCLI` class
186
+ 3. **New MCP tools**: Add to `MCPServer` class
187
+
188
+ ### Testing
189
+
190
+ ```bash
191
+ # Run Ruby linting
192
+ bundle exec standardrb
193
+
194
+ # Test CLI commands
195
+ mcpz slack test
196
+ mcpz slack channels
197
+
198
+ # Test MCP server manually
199
+ echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | mcpz slack mcp
200
+ ```
201
+
202
+ ## Contributing
203
+
204
+ 1. Follow existing code patterns and style
205
+ 2. Add comprehensive error handling
206
+ 3. Update this README for new features
207
+ 4. Test both CLI and MCP modes
208
+
209
+ ## License
210
+
211
+ This project follows the same license as the parent repository.