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.
@@ -3,278 +3,329 @@
3
3
 
4
4
  require "bundler/setup"
5
5
  require "json"
6
- require_relative "slack_tool"
6
+ require_relative "service"
7
7
 
8
- class MCPServer
9
- def initialize
10
- # Defer SlackTool initialization until actually needed
11
- @slack_tool = nil
12
- @tools = {
13
- "test_connection" => {
14
- name: "test_connection",
15
- description: "Test the Slack API connection",
16
- inputSchema: {
17
- type: "object",
18
- properties: {},
19
- required: []
20
- }
21
- },
22
- "list_channels" => {
23
- name: "list_channels",
24
- description: "List available Slack channels",
25
- inputSchema: {
26
- type: "object",
27
- properties: {},
28
- required: []
29
- }
30
- },
31
- "post_message" => {
32
- name: "post_message",
33
- description: "Post a message to a Slack channel",
34
- inputSchema: {
35
- type: "object",
36
- properties: {
37
- channel: {
38
- type: "string",
39
- description: "The Slack channel name (with or without #)"
40
- },
41
- text: {
42
- type: "string",
43
- description: "The message text to post"
8
+ module Slack
9
+ class MCPServer
10
+ def initialize
11
+ # Defer SlackTool initialization until actually needed
12
+ @slack_tool = nil
13
+ @tools = {
14
+ "test_connection" => {
15
+ name: "test_connection",
16
+ description: "Test the Slack API connection",
17
+ inputSchema: {
18
+ type: "object",
19
+ properties: {},
20
+ required: []
21
+ }
22
+ },
23
+ "list_channels" => {
24
+ name: "list_channels",
25
+ description: "List available Slack channels. When asked to list ALL channels, automatically retrieve all pages by calling this tool multiple times with the cursor parameter to get complete results.",
26
+ inputSchema: {
27
+ type: "object",
28
+ properties: {
29
+ limit: {
30
+ type: "number",
31
+ description: "Maximum number of channels to return (default: 100, max: 1000)"
32
+ },
33
+ cursor: {
34
+ type: "string",
35
+ description: "Cursor for pagination. Use the next_cursor from previous response to get next page"
36
+ },
37
+ exclude_archived: {
38
+ type: "boolean",
39
+ description: "Exclude archived channels from results (default: true)"
40
+ }
44
41
  },
45
- username: {
46
- type: "string",
47
- description: "Optional custom username for the message"
42
+ required: []
43
+ }
44
+ },
45
+ "post_message" => {
46
+ name: "post_message",
47
+ description: "Post a message to a Slack channel",
48
+ inputSchema: {
49
+ type: "object",
50
+ properties: {
51
+ channel: {
52
+ type: "string",
53
+ description: "The Slack channel name (with or without #)"
54
+ },
55
+ text: {
56
+ type: "string",
57
+ description: "The message text to post"
58
+ },
59
+ username: {
60
+ type: "string",
61
+ description: "Optional custom username for the message"
62
+ },
63
+ thread_ts: {
64
+ type: "string",
65
+ description: "Optional timestamp of parent message to reply to"
66
+ }
48
67
  },
49
- thread_ts: {
50
- type: "string",
51
- description: "Optional timestamp of parent message to reply to"
52
- }
53
- },
54
- required: ["channel", "text"]
68
+ required: ["channel", "text"]
69
+ }
55
70
  }
56
71
  }
57
- }
58
- end
59
-
60
- def run
61
- # Disable stdout buffering for immediate response
62
- $stdout.sync = true
63
-
64
- # Log startup to file instead of stdout to avoid protocol interference
65
- Mcpeasy::Config.ensure_config_dirs
66
- File.write(Mcpeasy::Config.log_file_path("slack", "startup"), "#{Time.now}: Slack MCP Server starting on stdio\n", mode: "a")
67
- while (line = $stdin.gets)
68
- handle_request(line.strip)
69
72
  end
70
- rescue Interrupt
71
- # Silent shutdown
72
- rescue => e
73
- # Log to a file instead of stderr to avoid protocol interference
74
- File.write(Mcpeasy::Config.log_file_path("slack", "error"), "#{Time.now}: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
75
- end
76
73
 
77
- private
74
+ def run
75
+ # Disable stdout buffering for immediate response
76
+ $stdout.sync = true
78
77
 
79
- def handle_request(line)
80
- return if line.empty?
81
-
82
- begin
83
- request = JSON.parse(line)
84
- response = process_request(request)
85
- if response
86
- puts JSON.generate(response)
87
- $stdout.flush
78
+ # Log startup to file instead of stdout to avoid protocol interference
79
+ Mcpeasy::Config.ensure_config_dirs
80
+ File.write(Mcpeasy::Config.log_file_path("slack", "startup"), "#{Time.now}: Slack MCP Server starting on stdio\n", mode: "a")
81
+ while (line = $stdin.gets)
82
+ handle_request(line.strip)
88
83
  end
89
- rescue JSON::ParserError => e
90
- error_response = {
91
- jsonrpc: "2.0",
92
- id: nil,
93
- error: {
94
- code: -32700,
95
- message: "Parse error",
96
- data: e.message
97
- }
98
- }
99
- puts JSON.generate(error_response)
100
- $stdout.flush
84
+ rescue Interrupt
85
+ # Silent shutdown
101
86
  rescue => e
102
- File.write(Mcpeasy::Config.log_file_path("slack", "error"), "#{Time.now}: Error handling request: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
103
- error_response = {
104
- jsonrpc: "2.0",
105
- id: request&.dig("id"),
106
- error: {
107
- code: -32603,
108
- message: "Internal error",
109
- data: e.message
110
- }
111
- }
112
- puts JSON.generate(error_response)
113
- $stdout.flush
87
+ # Log to a file instead of stderr to avoid protocol interference
88
+ File.write(Mcpeasy::Config.log_file_path("slack", "error"), "#{Time.now}: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
114
89
  end
115
- end
116
90
 
117
- def process_request(request)
118
- id = request["id"]
119
- method = request["method"]
120
- params = request["params"] || {}
91
+ private
121
92
 
122
- case method
123
- when "notifications/initialized"
124
- # Client acknowledgment - no response needed
125
- nil
126
- when "initialize"
127
- initialize_response(id, params)
128
- when "tools/list"
129
- tools_list_response(id, params)
130
- when "tools/call"
131
- tools_call_response(id, params)
132
- else
133
- {
134
- jsonrpc: "2.0",
135
- id: id,
136
- error: {
137
- code: -32601,
138
- message: "Method not found",
139
- data: "Unknown method: #{method}"
140
- }
141
- }
142
- end
143
- end
93
+ def handle_request(line)
94
+ return if line.empty?
144
95
 
145
- def initialize_response(id, params)
146
- {
147
- jsonrpc: "2.0",
148
- id: id,
149
- result: {
150
- protocolVersion: "2024-11-05",
151
- capabilities: {
152
- tools: {}
153
- },
154
- serverInfo: {
155
- name: "slack-mcp-server",
156
- version: "1.0.0"
96
+ begin
97
+ request = JSON.parse(line)
98
+ response = process_request(request)
99
+ if response
100
+ puts JSON.generate(response)
101
+ $stdout.flush
102
+ end
103
+ rescue JSON::ParserError => e
104
+ error_response = {
105
+ jsonrpc: "2.0",
106
+ id: nil,
107
+ error: {
108
+ code: -32700,
109
+ message: "Parse error",
110
+ data: e.message
111
+ }
157
112
  }
158
- }
159
- }
160
- end
161
-
162
- def tools_list_response(id, params)
163
- {
164
- jsonrpc: "2.0",
165
- id: id,
166
- result: {
167
- tools: @tools.values
168
- }
169
- }
170
- end
113
+ puts JSON.generate(error_response)
114
+ $stdout.flush
115
+ rescue => e
116
+ File.write(Mcpeasy::Config.log_file_path("slack", "error"), "#{Time.now}: Error handling request: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
117
+ error_response = {
118
+ jsonrpc: "2.0",
119
+ id: request&.dig("id"),
120
+ error: {
121
+ code: -32603,
122
+ message: "Internal error",
123
+ data: e.message
124
+ }
125
+ }
126
+ puts JSON.generate(error_response)
127
+ $stdout.flush
128
+ end
129
+ end
171
130
 
172
- def tools_call_response(id, params)
173
- tool_name = params["name"]
174
- arguments = params["arguments"] || {}
131
+ def process_request(request)
132
+ id = request["id"]
133
+ method = request["method"]
134
+ params = request["params"] || {}
175
135
 
176
- unless @tools.key?(tool_name)
177
- return {
178
- jsonrpc: "2.0",
179
- id: id,
180
- error: {
181
- code: -32602,
182
- message: "Unknown tool",
183
- data: "Tool '#{tool_name}' not found"
136
+ case method
137
+ when "notifications/initialized"
138
+ # Client acknowledgment - no response needed
139
+ nil
140
+ when "initialize"
141
+ initialize_response(id, params)
142
+ when "tools/list"
143
+ tools_list_response(id, params)
144
+ when "tools/call"
145
+ tools_call_response(id, params)
146
+ else
147
+ {
148
+ jsonrpc: "2.0",
149
+ id: id,
150
+ error: {
151
+ code: -32601,
152
+ message: "Method not found",
153
+ data: "Unknown method: #{method}"
154
+ }
184
155
  }
185
- }
156
+ end
186
157
  end
187
158
 
188
- begin
189
- result = call_tool(tool_name, arguments)
159
+ def initialize_response(id, params)
190
160
  {
191
161
  jsonrpc: "2.0",
192
162
  id: id,
193
163
  result: {
194
- content: [
195
- {
196
- type: "text",
197
- text: result
198
- }
199
- ],
200
- isError: false
164
+ protocolVersion: "2024-11-05",
165
+ capabilities: {
166
+ tools: {}
167
+ },
168
+ serverInfo: {
169
+ name: "slack-mcp-server",
170
+ version: "1.0.0"
171
+ }
201
172
  }
202
173
  }
203
- rescue => e
204
- File.write(Mcpeasy::Config.log_file_path("slack", "error"), "#{Time.now}: Tool error: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
174
+ end
175
+
176
+ def tools_list_response(id, params)
205
177
  {
206
178
  jsonrpc: "2.0",
207
179
  id: id,
208
180
  result: {
209
- content: [
210
- {
211
- type: "text",
212
- text: "❌ Error: #{e.message}"
213
- }
214
- ],
215
- isError: true
181
+ tools: @tools.values
216
182
  }
217
183
  }
218
184
  end
219
- end
220
185
 
221
- def call_tool(tool_name, arguments)
222
- # Initialize SlackTool only when needed
223
- @slack_tool ||= SlackTool.new
186
+ def tools_call_response(id, params)
187
+ tool_name = params["name"]
188
+ arguments = params["arguments"] || {}
224
189
 
225
- case tool_name
226
- when "test_connection"
227
- test_connection
228
- when "list_channels"
229
- list_channels
230
- when "post_message"
231
- post_message(arguments)
232
- else
233
- raise "Unknown tool: #{tool_name}"
234
- end
235
- end
190
+ unless @tools.key?(tool_name)
191
+ return {
192
+ jsonrpc: "2.0",
193
+ id: id,
194
+ error: {
195
+ code: -32602,
196
+ message: "Unknown tool",
197
+ data: "Tool '#{tool_name}' not found"
198
+ }
199
+ }
200
+ end
236
201
 
237
- def test_connection
238
- response = @slack_tool.test_connection
239
- if response["ok"]
240
- "✅ Successfully connected to Slack. Bot: #{response["user"]}, Team: #{response["team"]}"
241
- else
242
- raise "Authentication failed: #{response["error"]}"
202
+ begin
203
+ result = call_tool(tool_name, arguments)
204
+ {
205
+ jsonrpc: "2.0",
206
+ id: id,
207
+ result: {
208
+ content: [
209
+ {
210
+ type: "text",
211
+ text: result
212
+ }
213
+ ],
214
+ isError: false
215
+ }
216
+ }
217
+ rescue => e
218
+ File.write(Mcpeasy::Config.log_file_path("slack", "error"), "#{Time.now}: Tool error: #{e.message}\n#{e.backtrace.join("\n")}\n", mode: "a")
219
+ {
220
+ jsonrpc: "2.0",
221
+ id: id,
222
+ result: {
223
+ content: [
224
+ {
225
+ type: "text",
226
+ text: "❌ Error: #{e.message}"
227
+ }
228
+ ],
229
+ isError: true
230
+ }
231
+ }
232
+ end
243
233
  end
244
- end
245
234
 
246
- def list_channels
247
- channels = @slack_tool.list_channels
248
- output = "📋 #{channels.count} Available channels: "
249
- output << channels.map { |c| "##{c[:name]} (ID: #{c[:id]})" }.join(", ")
250
- output
251
- end
235
+ def call_tool(tool_name, arguments)
236
+ # Initialize Service only when needed
237
+ @slack_tool ||= Service.new
252
238
 
253
- def post_message(arguments)
254
- # Validate required arguments
255
- unless arguments["channel"]
256
- raise "Missing required argument: channel"
239
+ case tool_name
240
+ when "test_connection"
241
+ test_connection
242
+ when "list_channels"
243
+ list_channels(arguments)
244
+ when "post_message"
245
+ post_message(arguments)
246
+ else
247
+ raise "Unknown tool: #{tool_name}"
248
+ end
257
249
  end
258
- unless arguments["text"]
259
- raise "Missing required argument: text"
250
+
251
+ def test_connection
252
+ response = @slack_tool.test_connection
253
+ if response["ok"]
254
+ "✅ Successfully connected to Slack. Bot: #{response["user"]}, Team: #{response["team"]}"
255
+ else
256
+ raise "Authentication failed: #{response["error"]}"
257
+ end
258
+ end
259
+
260
+ def list_channels(arguments)
261
+ limit = [arguments["limit"]&.to_i || 100, 1000].min
262
+ cursor = arguments["cursor"]&.to_s
263
+ exclude_archived = arguments.key?("exclude_archived") ? arguments["exclude_archived"] : true
264
+
265
+ # Keep track of current page for display
266
+ @list_channels_page ||= {}
267
+ page_key = cursor || "first"
268
+ @list_channels_page[page_key] ||= 0
269
+ @list_channels_page[page_key] = cursor ? @list_channels_page[page_key] + 1 : 1
270
+
271
+ result = @slack_tool.list_channels(limit: limit, cursor: cursor&.empty? ? nil : cursor, exclude_archived: exclude_archived)
272
+ channels = result[:channels]
273
+
274
+ # Calculate record range
275
+ page_num = @list_channels_page[page_key]
276
+ start_index = (page_num - 1) * limit
277
+ end_index = start_index + channels.count - 1
278
+
279
+ channels_list = channels.map.with_index do |channel, i|
280
+ "##{channel[:name]} (ID: #{channel[:id]})"
281
+ end.join(", ")
282
+
283
+ pagination_info = if result[:has_more]
284
+ <<~INFO
285
+
286
+ 📄 **Page #{page_num}** | Showing channels #{start_index + 1}-#{end_index + 1}
287
+ _More channels available. Use `cursor: "#{result[:next_cursor]}"` to get the next page._
288
+ INFO
289
+ else
290
+ # Try to estimate total if we're on last page
291
+ estimated_total = start_index + channels.count
292
+ <<~INFO
293
+
294
+ 📄 **Page #{page_num}** | Showing channels #{start_index + 1}-#{end_index + 1} of #{estimated_total} total
295
+ INFO
296
+ end
297
+
298
+ <<~OUTPUT
299
+ 📋 #{channels.count} Available channels: #{channels_list}#{pagination_info}
300
+ OUTPUT
260
301
  end
261
302
 
262
- channel = arguments["channel"].to_s.sub(/^#/, "")
263
- text = arguments["text"].to_s
264
- username = arguments["username"]&.to_s
265
- thread_ts = arguments["thread_ts"]&.to_s
303
+ def post_message(arguments)
304
+ # Validate required arguments
305
+ unless arguments["channel"]
306
+ raise "Missing required argument: channel"
307
+ end
308
+ unless arguments["text"]
309
+ raise "Missing required argument: text"
310
+ end
266
311
 
267
- response = @slack_tool.post_message(
268
- channel: channel,
269
- text: text,
270
- username: username&.empty? ? nil : username,
271
- thread_ts: thread_ts&.empty? ? nil : thread_ts
272
- )
312
+ channel = arguments["channel"].to_s.sub(/^#/, "")
313
+ text = arguments["text"].to_s
314
+ username = arguments["username"]&.to_s
315
+ thread_ts = arguments["thread_ts"]&.to_s
273
316
 
274
- "✅ Message posted successfully to ##{channel} (Message timestamp: #{response["ts"]})"
317
+ response = @slack_tool.post_message(
318
+ channel: channel,
319
+ text: text,
320
+ username: username&.empty? ? nil : username,
321
+ thread_ts: thread_ts&.empty? ? nil : thread_ts
322
+ )
323
+
324
+ "✅ Message posted successfully to ##{channel} (Message timestamp: #{response["ts"]})"
325
+ end
275
326
  end
276
327
  end
277
328
 
278
329
  if __FILE__ == $0
279
- MCPServer.new.run
330
+ Slack::MCPServer.new.run
280
331
  end